# Project export: Dough's and Don'ts: The Game

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: An interactive game that lets you reshape California's economy, one doughnut ring at a time. Find the safe and just space for California where no one falls short and nothing overshoots.
- Devpost: https://devpost.com/software/dough-s-and-don-ts-the-game
- GitHub: https://github.com/ashay-sriv-06/treehacks-doughnut
- Demo: https://treehacks-doughnut-web.vercel.app/
- Video: https://www.youtube.com/embed/aLN2R7t6vLg?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Swastik Agrawal (14 commits), ashay-sriv-06 (14 commits), Claude Opus 4.6 (4 commits)

## Devpost submission (written by the team)

### Inspiration

California has the 4th largest economy in the world, yet 7.3 million residents live in poverty, 187,000 people are unhoused, and the state emits nearly 20x its fair share of CO2 per capita. The data exists, buried in reports and spreadsheets, but it never reaches people in a way that sticks. Doughnut Economics, a framework by Kate Raworth, reimagines the economy as a balance between two rings: a social foundation (the basics every person deserves: housing, health, food, education) and an ecological ceiling (the planetary boundaries we must not overshoot: climate, biodiversity, freshwater). The sweet spot between them, the green "doughnut," is the safe and just space for humanity. California desperately needs this lens. Not in a PDF. Not in a policy brief. In something citizens can actually feel. We wanted to build an experience where you don't just read that "99.9% of Californians live in counties above safe violent crime levels," you walk through it, pull a policy lever, and watch the doughnut heal. What It Does Doughs and Don'ts: The Game is a pixel-art interactive game meets data visualization, an interactive game where players explore California's 21 doughnut sectors and make real policy decisions. The Doughnut: The main menu presents a full interactive doughnut diagram with all 21 sectors: 12 social foundations (housing, health, education, food, equity, income and work, peace and justice, political voice, social cohesion, connectivity, energy, water and sanitation) and 9 ecological ceilings (climate change, biodiversity, air pollution, freshwater use, land use, chemical pollution, nitrogen and phosphorus loading, ocean acidification, ozone depletion). Every sector has a severity level, from SAFE (green) to CRITICAL (red). Click any wedge and a detailed panel reveals the real data: indicators, targets, shortfall/overshoot percentages, key facts, relevant legislation, and justice spotlights on who's most affected. The Game: Pick a persona (Explorer, Policymaker, Farmer, or Citizen) and drop into side-scrolling levels themed around specific sectors. Each persona unlocks different sectors: a Farmer can enter Freshwater Use, Food, and Land Use Change; a Policymaker unlocks Climate Change, Income and Work, and Peace and Justice. This isn't arbitrary. Perspective matters. The sectors you can influence depend on who you are in the system. Healing the Doughnut: Inside each level, players encounter evidence boards that surface real California statistics, and lever gates, which are decision points where you choose between policy options. Pull a lever, and the sector's severity actually decreases. Return to the main menu and watch the doughnut's red wedges shrink, replaced by green, a visual record of the healing you've done. Progress persists across sessions. How We Built It We kept the tech stack intentionally simple (React, TypeScript, Vite, and Canvas 2D) because our focus was on the art, flow, and feel of the experience. The doughnut visualization is drawn pixel-by-pixel on a canvas. The platformer runs on a custom game loop with its own gravity, collision, and camera systems. Sector data is sourced from real California datasets, normalized and structured into JSON files that feed both the visualization and the in-game evidence boards. D3.js handles data manipulation behind the scenes, but every visual, from the doughnut rings to the pixel-art characters to the particle effects, is hand-crafted. Challenges We Faced Mapping 21 real-world sectors into playable, meaningful game levels was the hardest design challenge. Each sector needed its own narrative, evidence, and policy levers, and the data had to be accurate, not gamified into meaninglessness. Balancing educational depth with gameplay flow required constant iteration. The persona-gating system also pushed us to think carefully: which roles should unlock which sectors, and why? We wanted the constraints to feel intentional, not frustrating, teaching players that systemic change requires different actors engaging different problems. On the technical side, building a full pixel-art platformer with physics, particle effects, a warp system, and a data-dense interactive doughnut, all on Canvas 2D without a game engine, meant debugging at the pixel level. Hit detection on wedge-shaped canvas regions, lerp-smoothed cameras, and persistence across 21 sector states all had their sharp edges. What We Learned Data visualization doesn't have to be passive. When you let people inhabit the data, walk through a level themed around California's housing crisis, pull a lever that represents a real policy, and watch the doughnut heal, the numbers stop being abstract. 65% housing cost burden becomes a world you've been in. We also learned that doughnut economics is a powerful storytelling framework. The dual-ring structure naturally creates tension (shortfall vs. overshoot) and a clear goal (find the green). It's the rare economic model that's also good game design.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 101 recognized source files, 1207 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (116 of 116)

```
.claude/settings.local.json
.gitignore
apps/web/index.html
apps/web/package.json
apps/web/public/housing_apr_normalized.csv
apps/web/src/main.tsx
apps/web/tsconfig.json
apps/web/vite.config.ts
data.json
eco_data.json
housing_apr_county_rollup.csv
housing_apr_normalized.csv
normalize_apr.py
package.json
packages/shared/package.json
packages/shared/tsconfig.json
social_data.json
sprites/generate_sprites.py
src/audio/SfxManager.ts
src/components/DonutDiagram/css.d.ts
src/components/DonutDiagram/donutConfig.ts
src/components/DonutDiagram/DonutDiagram.module.css
src/components/DonutDiagram/DonutDiagram.tsx
src/components/DonutDiagram/drawUtils.ts
src/components/DonutDiagram/hitDetection.ts
src/components/DonutDiagram/types.ts
src/components/DonutDiagram/useDonutRenderer.ts
src/components/doughnut/WireframeDoughnut.tsx
src/components/game/biomes/bedroom.ts
src/components/game/biomes/equityAlley.ts
src/components/game/biomes/housingStreet.ts
src/components/game/biomes/parallax.ts
src/components/game/biomes/registry.ts
src/components/game/biomes/scoreboard.ts
src/components/game/biomes/types.ts
src/components/game/camera.ts
src/components/game/camera/Camera.ts
src/components/game/constants.ts
src/components/game/debug/DragTeleport.ts
src/components/game/entities/BurningTree.ts
src/components/game/entities/MiniDonut.ts
src/components/game/entities/SkyDoughnut.ts
src/components/game/entities/Totem.ts
src/components/game/entities/XRayShrine.ts
src/components/game/fx/FireFx.ts
src/components/game/fx/OminousDoughnut.ts
src/components/game/fx/OminousSkyDoughnut.ts
src/components/game/fx/PunchFx.ts
src/components/game/fx/UfoBeamFx.ts
src/components/game/healingProgress.ts
src/components/game/hud/DataBoard.tsx
src/components/game/hud/DataCard.tsx
src/components/game/hud/HUD.tsx
src/components/game/hud/StatBoards.ts
src/components/game/interactions/Interactables.ts
src/components/game/modes/DoughnutFocusMode.ts
src/components/game/overlays/AltarOverlay.tsx
src/components/game/overlays/DoughnutOverlay.tsx
src/components/game/overlays/LeverGate.tsx
src/components/game/overlays/OverlayPortal.tsx
src/components/game/pages/AirPollutionPage.tsx
src/components/game/pages/DashboardPage.tsx
src/components/game/pages/DataGate.tsx
src/components/game/pages/DroughtBasinPage.tsx
src/components/game/pages/FireRidgePage.tsx
src/components/game/pages/FoodHarvestPage.tsx
src/components/game/pages/FreshwaterUsePage.tsx
src/components/game/pages/HealthPage.tsx
src/components/game/pages/HousingStreetPage.tsx
src/components/game/pages/IncomeWorkPage.tsx
src/components/game/pages/LandUsePage.tsx
src/components/game/pages/MenuPage.tsx
src/components/game/pages/OutsidePage.tsx
src/components/game/pages/PeaceJusticePage.tsx
src/components/game/pages/RunnerPage.tsx
src/components/game/personaConfig.ts
src/components/game/physics.ts
src/components/game/player/AnimationController.ts
src/components/game/player/Player.ts
src/components/game/player/spritesheetLoader.ts
src/components/game/scenario/engine.ts
src/components/game/scenario/questData.ts
src/components/game/scenario/questEngine.ts
src/components/game/scenario/types.ts
src/components/game/sprites.ts
src/components/game/stages/AirPollutionStage.tsx
src/components/game/stages/BedroomStage.tsx
src/components/game/stages/FireRidgeStage.tsx
src/components/game/stages/FoodHarvestStage.tsx
src/components/game/stages/FreshwaterUseStage.tsx
src/components/game/stages/HealthStage.tsx
src/components/game/stages/HousingStreetStage.tsx
src/components/game/stages/IncomeWorkStage.tsx
src/components/game/stages/LandUseChangeStage.tsx
src/components/game/stages/MainMenu.tsx
src/components/game/stages/OutsideStage.tsx
src/components/game/stages/PeaceJusticeStage.tsx
src/components/game/StoryMode.tsx
src/components/game/systems/LevelWarpSystem.ts
src/components/game/types.ts
src/components/game/useGameData.ts
src/components/game/useGameLoop.ts
src/components/game/useInput.ts
src/components/game/useStageNav.ts
src/components/housing/computeHousingMetrics.ts
src/components/housing/HousingPulsePanel.tsx
src/components/housing/useHousingPulse.ts
src/components/ui/DoughnutWheel.tsx
src/components/viz/AffordabilityMixChart.tsx
src/components/viz/CountyLeaderboard.tsx
src/components/viz/HealthGauge.tsx
src/components/viz/PipelineFlow.tsx
src/data/caRepresentatives.ts
STAGE2_TWEAKS.md
tsconfig.json
vercel.json
```

### Dependencies

- apps/web/package.json: @types/d3@^7.4.0, @types/react@^18.3.0, @types/react-dom@^18.3.0, @vitejs/plugin-react@^4.3.0, d3@^7.9.0, d3-dsv@^3.0.1, react@^18.3.0, react-dom@^18.3.0, react-router-dom@^7.13.0, typescript@^5.4.0, vite@^5.4.0
- package.json: @types/d3@^7.4.3, @types/react@^19.2.14, @types/react-dom@^19.2.3, d3@^7.9.0, echarts@^6.0.0, echarts-for-react@^3.0.6, lucide-react@^0.564.0, react@^19.2.4, react-dom@^19.2.4, typescript@^5.9.3
- packages/shared/package.json: typescript@^5.4.0, zod@^3.22.0

### Recent commits (newest first)

- Fix Vercel build: run vite from apps/web root directory
- Fix Vercel build: use direct vite build instead of npm workspaces
- final commit
- Merge branch 'main' of github.com:ashay-sriv-06/treehacks-doughnut
- minor change s
- fixed land-use-change animation problem
- Merge branch 'main' of https://github.com/ashay-sriv-06/treehacks-doughnut
- Persist healing progress in localStorage and reflect on main menu donut
- Fix cracked ground, scale up Fire Ridge signs, fix Peace Justice overflows
- home page changes and sht
- Make sky doughnut wedges persona-aware with lock icons for locked levels
- Add persona-gated level select, back button, and unify 2.5D sky doughnut
- Merge main into levels
- Additional changes to levels
- Replace pixel-art sky doughnut with mini doughnut economics diagram
- Levels all done, need to be edited but barebones is done
- Add Air Pollution level, fix signboards, walking animation, and smoke effects
- merged with levels
- Merge remote-tracking branch 'origin/levels' into doughnut
- routes added seperately for levels

## Key source files (fetched from GitHub, selected and truncated for size)

### STAGE2_TWEAKS.md

```markdown
# Stage 2: "The Donut in the Sky" — Tweak Guide

Quick reference for adjusting Chapter 2 visuals, data, and mechanics.

## Doughnut Position & Scale

| What | File | Constant |
|------|------|----------|
| Sky doughnut Y position | `src/components/game/stages/OutsideStage.tsx` | `DOUGHNUT_SCREEN_Y_FRAC` (default: 0.18 = 18% from top) |
| Sky doughnut pixel scale | `OutsideStage.tsx` | `DOUGHNUT_SCALE` (default: 3 = 80px sprite × 3 = 240px) |
| Doughnut sprite resolution | `src/components/game/entities/SkyDoughnut.ts` | `SPRITE_SIZE` (default: 80) |
| Doughnut ring radii | `SkyDoughnut.ts` | `OUTER_R`, `INNER_R` |
| Wedge gap size | `SkyDoughnut.ts` | `GAP_RAD` (radians) |

## X-Ray Shrine

| What | File | Constant |
|------|------|----------|
| Shrine world X | `src/components/game/entities/XRayShrine.ts` | `SHRINE_X` (default: 250) |
| Boot sequence duration | `XRayShrine.ts` | `BOOT_DURATION` (default: 1.5s) |
| Shrine interaction radius | `XRayShrine.ts` | `SHRINE_INTERACT_RADIUS` (default: 80) |

## Totems & Proximity FX

| What | File | Constant |
|------|------|----------|
| Totem world X positions | `src/components/game/entities/Totem.ts` | `STAGE2_TOTEMS[].worldX` (800, 1600, 2400) |
| Environmental FX radius | `Totem.ts` | `PROXIMITY_RADIUS` (default: 300px) |
| Totem interaction radius | `Totem.ts` | `interactRadius` in `createTotem()` (default: 70) |
| Totem stat text | `Totem.ts` | `STAGE2_TOTEMS[].statText` |

## Data & Stats

| What | File | Location |
|------|------|----------|
| Safe & Just % | `src/components/game/modes/DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.safeJustPercent` |
| Social shortfall text | `DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.socialShortfall` |
| Ecological overshoot text | `DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.ecologicalOvershoot` |
| Context board texts | `DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.population`, `.economyRank`, `.povertyRate`, `.airQuality` |
| Overshoot numbers | `DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.overshootNumbers[]` |
| Wedge fact lines | `DoughnutFocusMode.ts` | `STAGE2_WEDGES[].factLines` |
| Source citations | `DoughnutFocusMode.ts` | `CA_DOUGHNUT_STATS.source`, `STAGE2_WEDGES[].source` |

## Stat Board Positions

| What | File | Constant |
|------|------|----------|
| Main scan board X | `src/components/game/hud/StatBoards.ts` | `STAT_BOARD_X` (default: 450) |
| Horrifying numbers wall X | `StatBoards.ts` | `NUMBERS_WALL_X` (default: 700) |
| Context plaque positions | `StatBoards.ts` | `CONTEXT_PLAQUE_POSITIONS[]` |

## Sound Volumes

| What | File | Location |
|------|------|----------|
| Master volume | `src/audio/SfxManager.ts` | `SfxManager._volume` (default: 0.2) |
| Per-sound throttle | `SfxManager.ts` | `SFX_DEFS[name].throttleMs` |
| Shrine boot chime | `SfxManager.ts` | `shrine_boot` definition |
| Scan sweep sound | `SfxManager.ts` | `scan` definition |

## Fire Variant

| What | File | Constant |
|------|------|----------|
| Enable fire variant | `OutsideS
[truncated — 505 more characters]
```

### package.json

```
{
  "name": "cali-doughnut",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ],
  "scripts": {
    "dev": "npm run dev -w apps/web",
    "build": "npm run build -w packages/shared && npm run build -w apps/web",
    "test": "npm run test -w apps/web",
    "generate-data": "npx tsx scripts/generate-mock-data.ts"
  },
  "dependencies": {
    "d3": "^7.9.0",
    "echarts": "^6.0.0",
    "echarts-for-react": "^3.0.6",
    "lucide-react": "^0.564.0",
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "devDependencies": {
    "@types/d3": "^7.4.3",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "typescript": "^5.9.3"
  }
}

```

### packages/shared/package.json

```
{
  "name": "@cali-doughnut/shared",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "scripts": {
    "build": "tsc --emitDeclarationOnly"
  },
  "dependencies": {
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "typescript": "^5.4.0"
  }
}

```

### apps/web/package.json

```
{
  "name": "web",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "d3": "^7.9.0",
    "d3-dsv": "^3.0.1",
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "react-router-dom": "^7.13.0"
  },
  "devDependencies": {
    "@types/d3": "^7.4.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.4.0",
    "vite": "^5.4.0"
  }
}

```

### apps/web/src/main.tsx

```typescript
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";

import MenuPage from "@/components/game/pages/MenuPage";
import OutsidePage from "@/components/game/pages/OutsidePage";
import FireRidgePage from "@/components/game/pages/FireRidgePage";
import IncomeWorkPage from "@/components/game/pages/IncomeWorkPage";
import DroughtBasinPage from "@/components/game/pages/DroughtBasinPage";
import PeaceJusticePage from "@/components/game/pages/PeaceJusticePage";
import FreshwaterUsePage from "@/components/game/pages/FreshwaterUsePage";
import FoodHarvestPage from "@/components/game/pages/FoodHarvestPage";
import LandUsePage from "@/components/game/pages/LandUsePage";
import HousingStreetPage from "@/components/game/pages/HousingStreetPage";
import AirPollutionPage from "@/components/game/pages/AirPollutionPage";
import HealthPage from "@/components/game/pages/HealthPage";
import RunnerPage from "@/components/game/pages/RunnerPage";
import DashboardPage from "@/components/game/pages/DashboardPage";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<MenuPage />} />
        <Route path="/outside" element={<OutsidePage />} />
        <Route path="/fire-ridge" element={<FireRidgePage />} />
        <Route path="/income-work" element={<IncomeWorkPage />} />
        <Route path="/drought-basin" element={<DroughtBasinPage />} />
        <Route path="/peace-justice" element={<PeaceJusticePage />} />
        <Route path="/freshwater-use" element={<FreshwaterUsePage />} />
        <Route path="/food-harvest" element={<FoodHarvestPage />} />
        <Route path="/land-use" element={<LandUsePage />} />
        <Route path="/housing-street" element={<HousingStreetPage />} />
        <Route path="/air-pollution" element={<AirPollutionPage />} />
        <Route path="/health" element={<HealthPage />} />
        <Route path="/runner" element={<RunnerPage />} />
        <Route path="/dashboard" element={<DashboardPage />} />
      </Routes>
    </BrowserRouter>
  );
}

createRoot(document.getElementById("root")!).render(<App />);

```

### normalize_apr.py

```python
"""
Normalize CA APR Tables A, A2, C, F into a single housing_apr_normalized dataset
keyed by jurisdiction / year / income_category, then roll up to county.
"""

import pandas as pd
import numpy as np

# ── Paths ────────────────────────────────────────────────────────────────────
BASE = "/Users/ashaysrivastava/Downloads"
OUT  = "/Users/ashaysrivastava/cali-doughnut-treehacks"

# ── Standard income category names ───────────────────────────────────────────
# We harmonize across tables into 4 buckets:
#   very_low  (includes ELI where reported separately)
#   low
#   moderate
#   above_moderate
INCOME_CATS = ["very_low", "low", "moderate", "above_moderate"]
VALID_YEARS = set(range(2009, 2026))


def clean_rows(df, juris_col, year_col):
    """Drop rows with malformed jurisdiction names or invalid years."""
    before = len(df)
    df[year_col] = pd.to_numeric(df[year_col], errors="coerce")
    df = df[df[year_col].isin(VALID_YEARS)].copy()
    df[year_col] = df[year_col].astype(int)
    df = df[df[juris_col].notna() & (df[juris_col].str.len() <= 50)].copy()
    after = len(df)
    if before != after:
        print(f"  Cleaned {before - after:,} malformed rows ({after:,} remain)")
    return df


# ═══════════════════════════════════════════════════════════════════════════════
# 1.  TABLE A  –  Housing Development Applications
#     Grain: one row per project application
#     Income cols: VLOW_INCOME_DR/NDR, LOW_INCOME_DR/NDR, MOD_INCOME_DR/NDR,
#                  ABOVE_MOD_INCOME
#     We sum DR+NDR per category; aggregate to jurisdiction/year.
# ═══════════════════════════════════════════════════════════════════════════════
print("Loading Table A …")
ta = pd.read_csv(f"{BASE}/tablea.csv", low_memory=False)
ta.columns = ta.columns.str.strip()
ta = clean_rows(ta, "JURIS_NAME", "YEAR")

ta["very_low_proposed"]      = pd.to_numeric(ta["VLOW_INCOME_DR"], errors="coerce").fillna(0) + pd.to_numeric(ta["VLOW_INCOME_NDR"], errors="coerce").fillna(0)
ta["low_proposed"]           = pd.to_numeric(ta["LOW_INCOME_DR"], errors="coerce").fillna(0)  + pd.to_numeric(ta["LOW_INCOME_NDR"], errors="coerce").fillna(0)
ta["moderate_proposed"]      = pd.to_numeric(ta["MOD_INCOME_DR"], errors="coerce").fillna(0)  + pd.to_numeric(ta["MOD_INCOME_NDR"], errors="coerce").fillna(0)
ta["above_moderate_proposed"]= pd.to_numeric(ta["ABOVE_MOD_INCOME"], errors="coerce").fillna(0)

ta["total_proposed"]   = pd.to_numeric(ta["TOT_PROPOSED_UNITS"], errors="coerce").fillna(0)
ta["total_approved"]   = pd.to_numeric(ta["TOT_APPROVED_UNITS"], errors="coerce").fillna(0)
ta["total_disapproved"]= pd.to_numeric(ta["TOT_DISAPPROVED_UNITS"], errors="coerce").fillna(0)

ta_agg = (
    ta.groupby(["JURIS_NAME", "CNTY_NAME", "YEAR"], dropna=False)
    .agg(
        very_low_proposed=("very_low_proposed", "sum"),
        low_proposed=("low_proposed", "sum"),
        moderate_proposed=("moderate_proposed", "sum"),
        above_moderate_proposed=("above_moderate_proposed", "sum"),
        total_proposed=("total_proposed", "sum"),
        total_approved=("total_approved", "sum"),
        total_disapproved=("total_disapproved", "sum"),
        n_applications=("JURIS_NAME", "size"),
    )
    .reset_index()
    .rename(columns={"JURIS_NAME": "jurisdiction", "CNTY_NAME": "county", "YEAR": "year"})
)

# Melt to long form
ta_long = ta_agg.melt(
    id_vars=["jurisdiction", "county", "year",
             "total_proposed", "total_approved", "total_disapproved", "n_applications"],
    value_vars=["very_low_proposed", "low_proposed", "moderate_proposed", "above_moderate_proposed"],
    var_name="income_category",
    value_name="units_proposed",
)
ta_long["income_category"] = ta_long["income_category"].str.replace("_proposed", "")
ta_long["source_table"] = "A"

# ═══════════════════════════════════════════════════════════════════════════════
# 2.  TABLE A2  –  Permits & Completions
#     Grain: one row per project
#     Three stages: entitlements (VLOW_INCOME_DR/NDR …),
#                   building permits (BP_…), certificates of occupancy (CO_…)
# ═══════════════════════════════════════════════════════════════════════════════
print("Loading Table A2 …")
ta2 = pd.read_csv(f"{BASE}/tablea2.csv", low_memory=False)
ta2.columns = ta2.columns.str.strip()
ta2 = clean_rows(ta2, "JURIS_NAME", "YEAR")

for prefix, label in [("", "entitled"), ("BP_", "permitted"), ("CO_", "completed")]:
    ta2[f"very_low_{label}"]       = pd.to_numeric(ta2.get(f"{prefix}VLOW_INCOME_DR", 0), errors="coerce").fillna(0) + pd.to_numeric(ta2.get(f"{prefix}VLOW_INCOME_NDR", 0), errors="coerce").fillna(0)
    ta2[f"low_{label}"]            = pd.to_numeric(ta2.get(f"{prefix}LOW_INCOME_DR", 0), errors="coerce").fillna(0)  + pd.to_numeric(ta2.get(f"{prefix}LOW_INCOME_NDR", 0), errors="coerce").fillna(0)
    ta2[f"moderate_{label}"]       = pd.to_numeric(ta2.get(f"{prefix}MOD_INCOME_DR", 0), errors="coerce").fillna(0)  + pd.to_numeric(ta2.get(f"{prefix}MOD_INCOME_NDR", 0), errors="coerce").fillna(0)
    ta2[f"above_moderate_{label}"] = pd.to_numeric(ta2.get(f"{prefix}ABOVE_MOD_INCOME", 0), errors="coerce").fillna(0)

stage_cols = []
for label in ["entitled", "permitted", "completed"]:
    stage_cols += [f"very_low_{label}", f"low_{label}", f"moderate_{label}", f"above_moderate_{label}"]

ta2_agg = (
    ta2.groupby(["JURIS_NAME", "CNTY_NAME", "YEAR"], dropna=False)
    .agg(**{c: (c, "sum") for c in stage_cols},
         n_projects=("JURIS_NAME", "size"))
    .reset_index()
    .rename(columns={"JURIS_NAME": "jurisdiction", "CNTY_NAME": "county", "YEAR": "year"})
)

# Pivot stage columns into per-income-category rows with units_entitled/permitted/completed
ta2_records = []
for label in ["entitled", "permitted", "completed"]:
    cols = [f"{cat}_{label}" for cat in INCOME_CATS]
    melted = ta2_agg.melt(
        id_vars=["jurisdiction", "county", "year", "n_projects"],
        value_vars=cols,
        var_name="income_category",
        value_name=f"units_{label}",
    )
    melted["in
[truncated — 8659 more characters]
```

### sprites/generate_sprites.py

```python
"""Generate pixel character PNGs for each persona with unique accessories."""
from PIL import Image

SCALE = 16
W, H = 20, 26  # wider + taller to fit hats, bags, boots

SKIN = (245, 198, 160)
HAIR = (58, 37, 24)
EYES = (26, 26, 26)
NOSE = (196, 122, 90)
SHOES = (45, 45, 45)
OUT = "/Users/swastikagrawal/Documents/treehacks-doughnut/sprites"


def rect(img, x, y, w, h, color):
    for dy in range(h):
        for dx in range(w):
            if 0 <= x + dx < img.width and 0 <= y + dy < img.height:
                img.putpixel((x + dx, y + dy), color)


def px(img, x, y, color):
    if 0 <= x < img.width and 0 <= y < img.height:
        img.putpixel((x, y), color)


def draw_base(img, shirt, pants, hair_y=4):
    """Draw the base character body. Returns key Y positions."""
    # Hair
    rect(img, 7, hair_y, 6, 1, HAIR)
    rect(img, 6, hair_y + 1, 8, 2, HAIR)
    # Face
    rect(img, 6, hair_y + 3, 8, 4, SKIN)
    # Eyes
    rect(img, 8, hair_y + 4, 1, 1, EYES)
    rect(img, 12, hair_y + 4, 1, 1, EYES)
    # Nose
    rect(img, 9, hair_y + 5, 2, 1, NOSE)
    # Torso
    torso_y = hair_y + 7
    rect(img, 7, torso_y, 6, 5, shirt)
    # Arms
    rect(img, 5, torso_y + 1, 2, 3, shirt)
    rect(img, 13, torso_y + 1, 2, 3, shirt)
    # Hands
    rect(img, 5, torso_y + 4, 2, 1, SKIN)
    rect(img, 13, torso_y + 4, 2, 1, SKIN)
    # Legs
    legs_y = torso_y + 5
    rect(img, 7, legs_y, 3, 4, pants)
    rect(img, 10, legs_y, 3, 4, pants)
    # Shoes
    shoes_y = legs_y + 4
    rect(img, 6, shoes_y, 4, 1, SHOES)
    rect(img, 10, shoes_y, 4, 1, SHOES)

    return {"torso_y": torso_y, "legs_y": legs_y, "shoes_y": shoes_y, "hair_y": hair_y}


def draw_policymaker(img):
    shirt = (59, 130, 246)
    pants = (30, 58, 95)
    pos = draw_base(img, shirt, pants)

    # Tie — dark red, runs down center of torso
    tie_color = (180, 30, 30)
    tie_knot = (150, 20, 20)
    ty = pos["torso_y"]
    # Knot at collar
    rect(img, 9, ty, 2, 1, tie_knot)
    # Tie body
    rect(img, 9, ty + 1, 2, 3, tie_color)
    # Tie point
    px(img, 9, ty + 4, tie_color)
    px(img, 10, ty + 4, tie_color)
    px(img, 9, ty + 4, tie_color)

    # Collar lines (white) on either side of tie
    collar = (220, 220, 230)
    px(img, 8, ty, collar)
    px(img, 11, ty, collar)


def draw_farmer(img):
    shirt = (34, 197, 94)
    pants = (20, 83, 45)
    # Farmer gets a straw hat — draw body lower to make room
    pos = draw_base(img, shirt, pants, hair_y=6)

    # Straw hat — sits on top of head, covering hair
    hat_main = (210, 180, 100)
    hat_dark = (180, 150, 80)
    hat_band = (120, 80, 40)
    hy = pos["hair_y"]
    # Hat brim (wide) — sits right on the hairline
    rect(img, 4, hy + 2, 12, 1, hat_main)
    # Hat crown — covers the hair
    rect(img, 6, hy, 8, 2, hat_main)
    # Hat top
    rect(img, 7, hy - 1, 6, 1, hat_dark)
    # Hat band
    rect(img, 6, hy + 1, 8, 1, hat_band)

    # Overalls straps over shirt
    strap = (70, 130, 200)
    ty = pos["torso_y"]
    px(img, 8, ty, strap)
    px(img, 8, ty + 1, strap)
    px(img, 8, ty + 2, strap)
    px(img, 11, ty, strap)
    px(img, 11, ty + 1, strap)
    px(img, 11, ty + 2, strap)


def draw_citizen(img):
    shirt = (249, 115, 22)
    pants = (124, 45, 18)
    pos = draw_base(img, shirt, pants)

    # Hoodie — hood behind head + front pocket
    hood_color = (220, 100, 15)
    pocket_color = (200, 90, 10)
    string_color = (240, 200, 160)
    hy = pos["hair_y"]
    ty = pos["torso_y"]

    # Hood outline behind head
    px(img, 5, hy + 1, hood_color)
    px(img, 5, hy + 2, hood_color)
    px(img, 5, hy + 3, hood_color)
    px(img, 5, hy + 4, hood_color)
    px(img, 5, hy + 5, hood_color)
    px(img, 14, hy + 1, hood_color)
    px(img, 14, hy + 2, hood_color)
    px(img, 14, hy + 3, hood_color)
    px(img, 14, hy + 4, hood_color)
    px(img, 14, hy + 5, hood_color)
    # Hood top
    px(img, 6, hy, hood_color)
    px(img, 13, hy, hood_color)

    # Kangaroo pocket on torso
    rect(img, 8, ty + 3, 4, 2, pocket_color)

    # Drawstrings
    px(img, 9, ty, string_color)
    px(img, 10, ty, string_color)
    px(img, 9, ty + 1, string_color)
    px(img, 10, ty + 1, string_color)


def draw_explorer(img):
    shirt = (148, 163, 184)
    pants = (71, 85, 105)
    pos = draw_base(img, shirt, pants)

    # Hiking boots — taller, brown
    boot_color = (120, 80, 40)
    boot_sole = (60, 40, 20)
    sy = pos["shoes_y"]
    ly = pos["legs_y"]
    # Boots go up over ankles
    rect(img, 6, ly + 3, 4, 1, boot_color)
    rect(img, 10, ly + 3, 4, 1, boot_color)
    rect(img, 6, sy, 4, 1, boot_color)
    rect(img, 10, sy, 4, 1, boot_color)
    # Soles (darker, extend a pixel wider)
    rect(img, 5, sy + 1, 5, 1, boot_sole)
    rect(img, 10, sy + 1, 5, 1, boot_sole)

    # Compass on belt
    belt = (80, 70, 60)
    compass = (200, 180, 50)
    rect(img, 7, ly, 6, 1, belt)
    px(img, 12, ly, compass)


DRAW_FNS = {
    "policymaker": draw_policymaker,
    "farmer": draw_farmer,
    "citizen": draw_citizen,
    "explorer": draw_explorer,
}

for name, draw_fn in DRAW_FNS.items():
    img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    draw_fn(img)
    img_scaled = img.resize((W * SCALE, H * SCALE), Image.NEAREST)
    img_scaled.save(f"{OUT}/{name}.png")
    print(f"Saved {name}.png ({W * SCALE}x{H * SCALE})")

```

### apps/web/vite.config.ts

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "../../src"),
    },
  },
});

```

### apps/web/index.html

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Housing Pulse Panel</title>
    <style>
      * { margin: 0; padding: 0; box-sizing: border-box; }
      body {
        background: #020617;
        min-height: 100vh;
        overflow: hidden;
      }
      #root { width: 100%; height: 100vh; }
      /* Dashboard mode restore scrolling */
      body.dashboard-mode {
        overflow: auto;
      }
      body.dashboard-mode #root {
        max-width: 1000px;
        height: auto;
        margin: 0 auto;
        padding: 32px 16px;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

```

### src/audio/SfxManager.ts

```typescript
/**
 * SfxManager.ts
 *
 * WebAudio-based sound effects manager. All sounds are synthesized
 * procedurally — no external audio files needed.
 *
 * Features:
 * - Preloaded AudioBuffer pool
 * - Throttled footsteps (max once per 140ms)
 * - Global volume control (default 0.2)
 * - Loop support with named keys
 */

type SfxName = "walk" | "jump" | "land" | "punch" | "interact" | "warp_beam" | "warp_drop" | "shrine_boot" | "shrine_done" | "scan" | "fire_crackle" | "branch_snap" | "lever_pull" | "lever_impact" | "siren_distant" | "desert_wind";

interface SfxDef {
  /** Generate an AudioBuffer for this sound */
  generate: (ctx: OfflineAudioContext) => void;
  /** Duration in seconds */
  duration: number;
  /** Minimum interval between plays (ms). 0 = no throttle */
  throttleMs: number;
}

const SFX_DEFS: Record<SfxName, SfxDef> = {
  walk: {
    duration: 0.08,
    throttleMs: 140,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "square";
      osc.frequency.setValueAtTime(180, 0);
      osc.frequency.exponentialRampToValueAtTime(80, 0.08);
      gain.gain.setValueAtTime(0.3, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.08);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.08);
    },
  },
  jump: {
    duration: 0.15,
    throttleMs: 100,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "square";
      osc.frequency.setValueAtTime(200, 0);
      osc.frequency.exponentialRampToValueAtTime(600, 0.1);
      osc.frequency.exponentialRampToValueAtTime(400, 0.15);
      gain.gain.setValueAtTime(0.25, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.15);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.15);
    },
  },
  land: {
    duration: 0.12,
    throttleMs: 100,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "triangle";
      osc.frequency.setValueAtTime(120, 0);
      osc.frequency.exponentialRampToValueAtTime(40, 0.12);
      gain.gain.setValueAtTime(0.3, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.12);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.12);
      // Noise burst for thud
      const noise = ctx.createBufferSource();
      const noiseLen = Math.floor(ctx.sampleRate * 0.06);
      const noiseBuf = ctx.createBuffer(1, noiseLen, ctx.sampleRate);
      const data = noiseBuf.getChannelData(0);
      for (let i = 0; i < noiseLen; i++) data[i] = (Math.random() * 2 - 1) * 0.3;
      noise.buffer = noiseBuf;
      const ng = ctx.createGain();
      ng.gain.setValueAtTime(0.15, 0);
      ng.gain.exponentialRampToValueAtTime(0.01, 0.06);
      noise.connect(ng).connect(ctx.destination);
      noise.start(0);
      noise.stop(0.06);
    },
  },
  punch: {
    duration: 0.12,
    throttleMs: 250,
    generate(ctx) {
      // Noise burst + tone sweep
      const noise = ctx.createBufferSource();
      const noiseLen = Math.floor(ctx.sampleRate * 0.08);
      const noiseBuf = ctx.createBuffer(1, noiseLen, ctx.sampleRate);
      const data = noiseBuf.getChannelData(0);
      for (let i = 0; i < noiseLen; i++) data[i] = (Math.random() * 2 - 1) * 0.5;
      noise.buffer = noiseBuf;
      const ng = ctx.createGain();
      ng.gain.setValueAtTime(0.3, 0);
      ng.gain.exponentialRampToValueAtTime(0.01, 0.08);
      noise.connect(ng).connect(ctx.destination);
      noise.start(0);
      noise.stop(0.08);

      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "sawtooth";
      osc.frequency.setValueAtTime(300, 0);
      osc.frequency.exponentialRampToValueAtTime(60, 0.12);
      gain.gain.setValueAtTime(0.2, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.12);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.12);
    },
  },
  interact: {
    duration: 0.1,
    throttleMs: 200,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "sine";
      osc.frequency.setValueAtTime(800, 0);
      osc.frequency.exponentialRampToValueAtTime(1200, 0.05);
      osc.frequency.exponentialRampToValueAtTime(900, 0.1);
      gain.gain.setValueAtTime(0.2, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.1);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.1);
    },
  },
  warp_beam: {
    duration: 0.8,
    throttleMs: 500,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "sine";
      osc.frequency.setValueAtTime(200, 0);
      osc.frequency.exponentialRampToValueAtTime(1400, 0.4);
      osc.frequency.exponentialRampToValueAtTime(600, 0.8);
      gain.gain.setValueAtTime(0.15, 0);
      gain.gain.setValueAtTime(0.2, 0.3);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.8);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.8);
    },
  },
  warp_drop: {
    duration: 0.25,
    throttleMs: 200,
    generate(ctx) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "triangle";
      osc.frequency.setValueAtTime(600, 0);
      osc.frequency.exponentialRampToValueAtTime(80, 0.2);
      gain.gain.setValueAtTime(0.25, 0);
      gain.gain.exponentialRampToValueAtTime(0.01, 0.25);
      osc.connect(gain).connect(ctx.destination);
      osc.start(0);
      osc.stop(0.25);
    },
  },
  shrine_boot: {
    duration: 0.6,
    throttleMs: 500,
    generate(ctx) {
      // Ascending chime: three tones in sequence
      const notes = [440, 554, 659]; // A4, C#5, E5
      notes.forEach((freq, i) => {
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = "sine";
        osc.frequency.setVal
[truncated — 10637 more characters]
```

[84 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]