From e10068aa7830fc57d110517512a495635bb113ea Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 17:47:16 -0300 Subject: [PATCH 1/5] feat(hedgehog-mode): spawn knockable block piles for Max to throw Adds Angry-Birds-style block piles to hedgehog mode. While the mode is on, stacks of 3-5 colored blocks spawn near the ground (capped at 3 piles, refreshed every ~15s) so the user has something to fling Max at. Blocks use the existing matter-js world inside `@posthog/hedgehog-mode` and are rendered with Pixi `Graphics` on the same canvas; cleanup happens when a block leaves the viewport or stays at rest for ~8s. - New `PileSpawner` owns the timer, the per-block `GameElement`, and lifecycle. Blocks use `category: PROJECTILE, mask: 0xFFFF` so Max (and the ground) collide with them out of the box. - `HedgehogMode.tsx` instantiates the spawner once the game is rendered and tears it down before destroying the game. - `pixi.js` and `matter-js` were already transitive deps via `@posthog/hedgehog-mode`; promoted to direct deps and added `@types/matter-js` so the imports type-check. Generated-By: PostHog Code Task-Id: f26ab8fc-0c36-4bf9-959b-891c6eda2e7b --- apps/code/package.json | 3 + .../src/renderer/components/HedgehogMode.tsx | 9 + .../components/hedgehog-mode/PileSpawner.ts | 162 ++++++++++++++++++ pnpm-lock.yaml | 14 ++ 4 files changed, 188 insertions(+) create mode 100644 apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts diff --git a/apps/code/package.json b/apps/code/package.json index 7c0f9d430..67f259066 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -60,6 +60,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", + "@types/matter-js": "^0.20.0", "@types/node": "^24.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", @@ -173,10 +174,12 @@ "inversify": "^7.10.6", "is-glob": "^4.0.3", "lucide-react": "^1.7.0", + "matter-js": "^0.20.0", "micromatch": "^4.0.5", "node-addon-api": "^8.5.0", "node-machine-id": "^1.1.12", "node-pty": "1.1.0", + "pixi.js": "^8.16.0", "posthog-js": "^1.283.0", "posthog-node": "^5.24.10", "radix-themes-tw": "0.2.3", diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index cc3d79584..dd3dbfc91 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -1,3 +1,4 @@ +import { PileSpawner } from "@components/hedgehog-mode/PileSpawner"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useMeQuery } from "@hooks/useMeQuery"; import type { @@ -15,6 +16,7 @@ export function HedgehogMode() { const { data: user } = useMeQuery(); const containerRef = useRef(null); const gameRef = useRef(null); + const pileSpawnerRef = useRef(null); useEffect(() => { if (!hedgehogMode || !containerRef.current || gameRef.current) return; @@ -52,6 +54,9 @@ export function HedgehogMode() { try { await game.render(container); log.info("Game rendered, hedgehogs:", game.getAllHedgehogs().length); + if (!cancelled) { + pileSpawnerRef.current = new PileSpawner(game); + } } catch (err) { log.error("Game render failed", err); } @@ -67,6 +72,10 @@ export function HedgehogMode() { useEffect(() => { return () => { + if (pileSpawnerRef.current) { + pileSpawnerRef.current.destroy(); + pileSpawnerRef.current = null; + } if (gameRef.current) { gameRef.current.destroy(); gameRef.current = null; diff --git a/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts b/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts new file mode 100644 index 000000000..81273eab5 --- /dev/null +++ b/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts @@ -0,0 +1,162 @@ +import type { + GameElement, + HedgeHogMode, + UpdateTicker, +} from "@posthog/hedgehog-mode"; +import Matter from "matter-js"; +import { Graphics } from "pixi.js"; + +const PROJECTILE_CATEGORY = 0x0004; +const MAX_PILES = 3; +const SPAWN_INTERVAL_MS = 15_000; +const BLOCK_SIZE = 32; +const REST_LIFETIME_MS = 8_000; +const REST_SPEED_THRESHOLD = 0.05; +const PILE_HEIGHT_MIN = 3; +const PILE_HEIGHT_MAX = 5; +const PALETTE = [0xf8c537, 0xe36588, 0x5bc0eb, 0x9bc53d, 0xfde74c, 0xc792ea]; + +class PileBlock implements GameElement { + readonly rigidBody: Matter.Body; + readonly isInteractive = false; + readonly pileId: number; + private readonly graphics: Graphics; + private readonly game: HedgeHogMode; + private restMs = 0; + private dead = false; + + constructor( + game: HedgeHogMode, + pileId: number, + x: number, + y: number, + color: number, + ) { + this.game = game; + this.pileId = pileId; + + this.rigidBody = Matter.Bodies.rectangle(x, y, BLOCK_SIZE, BLOCK_SIZE, { + density: 0.0015, + friction: 0.5, + frictionAir: 0.01, + restitution: 0.15, + label: "PileBlock", + collisionFilter: { + category: PROJECTILE_CATEGORY, + mask: 0xffff, + }, + }); + Matter.Composite.add(game.engine.world, this.rigidBody); + + this.graphics = new Graphics() + .rect(-BLOCK_SIZE / 2, -BLOCK_SIZE / 2, BLOCK_SIZE, BLOCK_SIZE) + .fill(color) + .stroke({ width: 1, color: 0x000000, alpha: 0.3 }); + this.graphics.position.set(x, y); + game.app.stage.addChild(this.graphics); + } + + update({ deltaMS }: UpdateTicker): void { + if (this.dead) return; + const body = this.rigidBody; + this.graphics.position.set(body.position.x, body.position.y); + this.graphics.rotation = body.angle; + + if ( + body.position.y > window.innerHeight + 400 || + body.position.x < -400 || + body.position.x > window.innerWidth + 400 + ) { + this.dead = true; + return; + } + + if (body.speed < REST_SPEED_THRESHOLD) { + this.restMs += deltaMS; + if (this.restMs > REST_LIFETIME_MS) { + this.dead = true; + } + } else { + this.restMs = 0; + } + } + + beforeUnload(): void { + this.game.app.stage.removeChild(this.graphics); + this.graphics.destroy(); + } + + get isDead(): boolean { + return this.dead; + } +} + +export class PileSpawner { + private readonly game: HedgeHogMode; + private readonly timer: ReturnType; + private readonly blocks = new Set(); + private nextPileId = 1; + private destroyed = false; + + constructor(game: HedgeHogMode) { + this.game = game; + this.spawnPile(); + this.timer = setInterval(() => this.tick(), SPAWN_INTERVAL_MS); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + clearInterval(this.timer); + for (const block of this.blocks) { + this.game.removeElement(block); + } + this.blocks.clear(); + } + + private tick(): void { + if (this.destroyed) return; + + for (const block of [...this.blocks]) { + if (block.isDead) { + this.game.removeElement(block); + this.blocks.delete(block); + } + } + + const livePileIds = new Set(); + for (const block of this.blocks) { + livePileIds.add(block.pileId); + } + + if (livePileIds.size < MAX_PILES) { + this.spawnPile(); + } + } + + private spawnPile(): void { + if (this.destroyed) return; + + const margin = 80; + const xRange = Math.max(window.innerWidth - margin * 2, 200); + const x = margin + Math.random() * xRange; + const groundTopY = window.innerHeight; + const height = + PILE_HEIGHT_MIN + + Math.floor(Math.random() * (PILE_HEIGHT_MAX - PILE_HEIGHT_MIN + 1)); + const color = PALETTE[Math.floor(Math.random() * PALETTE.length)]; + const pileId = this.nextPileId++; + + for (let i = 0; i < height; i++) { + const block = new PileBlock( + this.game, + pileId, + x + (Math.random() - 0.5) * 4, + groundTopY - BLOCK_SIZE / 2 - i * BLOCK_SIZE, + color, + ); + this.game.elements.push(block); + this.blocks.add(block); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d41dfe7bc..202f3718f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.1.0) + matter-js: + specifier: ^0.20.0 + version: 0.20.0 micromatch: specifier: ^4.0.5 version: 4.0.8 @@ -307,6 +310,9 @@ importers: node-pty: specifier: 1.1.0 version: 1.1.0(patch_hash=4dfdf785f5ac51a03f5d6032371cebe89036381acd403621f250a896245647c5) + pixi.js: + specifier: ^8.16.0 + version: 8.16.0 posthog-js: specifier: ^1.283.0 version: 1.340.0 @@ -440,6 +446,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/matter-js': + specifier: ^0.20.0 + version: 0.20.2 '@types/node': specifier: ^24.0.0 version: 24.12.0 @@ -5409,6 +5418,9 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/matter-js@0.20.2': + resolution: {integrity: sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -17576,6 +17588,8 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/matter-js@0.20.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 From f7460d28869e9f8a01bcc0176c570b5edd33425f Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 18:02:30 -0300 Subject: [PATCH 2/5] fix(hedgehog-mode): keep game alive across remounts; taller piles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Stop leaking `window.pointerdown` listeners in dev. The upstream `@posthog/hedgehog-mode` `destroy()` doesn't remove its capture-phase window listeners, so every StrictMode simulated unmount and every Vite HMR cycle stacked another stale listener on top. The old listener fired first on each click, hit-tested against the destroyed game's hedgehog, and called `preventDefault/stopPropagation` — so the live game never saw the click and you couldn't grab Max in dev. Fix locally by only calling `destroy()` when `hedgehogMode` actually transitions to false, instead of on every effect cleanup. Production was unaffected (no HMR, no StrictMode double-mount), which matches the reported symptom. 2. Bump pile height range from 3-5 to 6-10 blocks so towers feel like real obstacles instead of speed bumps. Generated-By: PostHog Code Task-Id: f26ab8fc-0c36-4bf9-959b-891c6eda2e7b --- .../src/renderer/components/HedgehogMode.tsx | 27 +++++++++---------- .../components/hedgehog-mode/PileSpawner.ts | 4 +-- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index dd3dbfc91..e3296a39d 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -19,7 +19,19 @@ export function HedgehogMode() { const pileSpawnerRef = useRef(null); useEffect(() => { - if (!hedgehogMode || !containerRef.current || gameRef.current) return; + if (!hedgehogMode) { + if (pileSpawnerRef.current) { + pileSpawnerRef.current.destroy(); + pileSpawnerRef.current = null; + } + if (gameRef.current) { + gameRef.current.destroy(); + gameRef.current = null; + } + return; + } + + if (!containerRef.current || gameRef.current) return; let cancelled = false; const container = containerRef.current; @@ -70,19 +82,6 @@ export function HedgehogMode() { }; }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode]); - useEffect(() => { - return () => { - if (pileSpawnerRef.current) { - pileSpawnerRef.current.destroy(); - pileSpawnerRef.current = null; - } - if (gameRef.current) { - gameRef.current.destroy(); - gameRef.current = null; - } - }; - }, []); - return (
Date: Fri, 22 May 2026 18:13:57 -0300 Subject: [PATCH 3/5] chore(hedgehog-mode): diagnostics for missing pile blocks Three small changes to figure out why piles aren't rendering in dev: - Drop the Pixi v8 `.rect().fill().stroke()` chain in favor of separate calls. Stroke was decorative and the chain is the likeliest silent-failure point. - Bump pile-block `zIndex` to 1000 so blocks render above the hedgehog if Pixi z-order is the issue. - Add a scoped `pile-spawner` logger that logs pile id, x, height, ground top, stage child count, and matter-js world body count on every spawn. - Expose the game on `window.__hedgehogGame` so we can inspect `app.stage.children`, `engine.world.bodies`, and `elements` from the renderer devtools console. Generated-By: PostHog Code Task-Id: f26ab8fc-0c36-4bf9-959b-891c6eda2e7b --- .../src/renderer/components/HedgehogMode.tsx | 3 +++ .../components/hedgehog-mode/PileSpawner.ts | 24 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index e3296a39d..c8977a70a 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -62,6 +62,9 @@ export function HedgehogMode() { }); gameRef.current = game; + ( + window as unknown as { __hedgehogGame?: HedgehogModeGame } + ).__hedgehogGame = game; try { await game.render(container); diff --git a/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts b/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts index 0d686bf67..8e742ff35 100644 --- a/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts +++ b/apps/code/src/renderer/components/hedgehog-mode/PileSpawner.ts @@ -3,9 +3,12 @@ import type { HedgeHogMode, UpdateTicker, } from "@posthog/hedgehog-mode"; +import { logger } from "@utils/logger"; import Matter from "matter-js"; import { Graphics } from "pixi.js"; +const log = logger.scope("pile-spawner"); + const PROJECTILE_CATEGORY = 0x0004; const MAX_PILES = 3; const SPAWN_INTERVAL_MS = 15_000; @@ -48,11 +51,16 @@ class PileBlock implements GameElement { }); Matter.Composite.add(game.engine.world, this.rigidBody); - this.graphics = new Graphics() - .rect(-BLOCK_SIZE / 2, -BLOCK_SIZE / 2, BLOCK_SIZE, BLOCK_SIZE) - .fill(color) - .stroke({ width: 1, color: 0x000000, alpha: 0.3 }); + this.graphics = new Graphics(); + this.graphics.rect( + -BLOCK_SIZE / 2, + -BLOCK_SIZE / 2, + BLOCK_SIZE, + BLOCK_SIZE, + ); + this.graphics.fill(color); this.graphics.position.set(x, y); + this.graphics.zIndex = 1000; game.app.stage.addChild(this.graphics); } @@ -158,5 +166,13 @@ export class PileSpawner { this.game.elements.push(block); this.blocks.add(block); } + log.info("Spawned pile", { + pileId, + x, + height, + groundTopY, + stageChildren: this.game.app.stage.children.length, + worldBodies: this.game.engine.world.bodies.length, + }); } } From 885420e4044a705a6aaf236dfc38d8deb2330344 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 18:17:59 -0300 Subject: [PATCH 4/5] fix(hedgehog-mode): gate spawner on game identity, not effect cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PileSpawner was being skipped whenever the effect re-ran while `game.render(container)` was still awaiting. The deps array includes `user?.hedgehog_config`, which React Query returns as a fresh object on every refetch — that retriggers the effect, fires the cleanup (`cancelled = true`), and the re-run bails on `gameRef.current` already being set. When the original `render` finally resolves, `!cancelled` is false, so the spawner is never constructed. The hedgehog itself survives because it's added inside `render`, before the await resolves. `cancelled` tracks effect lifecycle, which is the wrong signal here. Gate on game identity instead: if `gameRef.current` still points to the game we just rendered, we're still the active owner and the spawner should be set up. Generated-By: PostHog Code Task-Id: f26ab8fc-0c36-4bf9-959b-891c6eda2e7b --- apps/code/src/renderer/components/HedgehogMode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index c8977a70a..69ec773ad 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -69,7 +69,7 @@ export function HedgehogMode() { try { await game.render(container); log.info("Game rendered, hedgehogs:", game.getAllHedgehogs().length); - if (!cancelled) { + if (gameRef.current === game) { pileSpawnerRef.current = new PileSpawner(game); } } catch (err) { From 7e739e020ed80c03b728ef4ce3859156e407cf1d Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 18:38:15 -0300 Subject: [PATCH 5/5] fix(hedgehog-mode): lazy-load PileSpawner to keep pixi.js out of main chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI integration-test was OOMing during electron-forge's renderer Vite build (4GB heap exhausted). The regression was the static import of `pixi.js` in `PileSpawner.ts`, combined with `PileSpawner` being statically imported by `HedgehogMode.tsx` — that pulled pixi.js (multi-MB WebGL bundle) into the main renderer chunk. Before this PR, pixi was only reachable through the dynamic `import("@posthog/hedgehog-mode")` and lived in a lazy chunk. Switch `PileSpawner` to a type-only import at the top of `HedgehogMode.tsx`, and load the actual module via `import()` alongside `@posthog/hedgehog-mode` inside the same effect. Both now share the lazy boundary, and pixi.js stays out of the main bundle. Generated-By: PostHog Code Task-Id: f26ab8fc-0c36-4bf9-959b-891c6eda2e7b --- apps/code/src/renderer/components/HedgehogMode.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx index 69ec773ad..1f50c1d42 100644 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ b/apps/code/src/renderer/components/HedgehogMode.tsx @@ -1,4 +1,4 @@ -import { PileSpawner } from "@components/hedgehog-mode/PileSpawner"; +import type { PileSpawner } from "@components/hedgehog-mode/PileSpawner"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useMeQuery } from "@hooks/useMeQuery"; import type { @@ -44,8 +44,11 @@ export function HedgehogMode() { | HedgehogActorOptions | undefined; - import("@posthog/hedgehog-mode") - .then(async ({ HedgeHogMode }) => { + Promise.all([ + import("@posthog/hedgehog-mode"), + import("@components/hedgehog-mode/PileSpawner"), + ]) + .then(async ([{ HedgeHogMode }, { PileSpawner }]) => { if (cancelled) return; log.info("Creating hedgehog game instance");