I Built a 'Living World' Eco App with AI - Here's Every Architectural Decision That Mattered

typescript dev.to

TL;DR: I built EcoAscent; a gamified eco-awareness app where a hand-drawn SVG world visually decays or flourishes based on your logged actions. This post documents the problem I was actually solving, why I picked React + Vitest + TanStack Start, how my prompts evolved across three distinct phases, and the two architectural decisions that made the whole thing work cleanly.


The Problem Nobody Told Me to Solve

Most eco-tracking apps fail for the same reason fitness apps fail: they turn a deeply personal, emotionally charged behaviour change into a spreadsheet. You log your actions, you see a number go up, you feel nothing, you stop.

The PromptWars Virtual Challenge 3 brief was deliberately open: build something that raises environmental awareness. The lazy interpretation is a CO₂ dashboard. Bar charts, a running total, maybe a comparison to "the average person." Useful, yes. Motivating, no.

The root problem I chose to target was this:

People don't lack data about climate change. They lack emotional connection to their own impact.

That framing changed every decision that followed. I wasn't building a tracker. I was building a feedback loop; one where small actions produce visible, immediate, satisfying changes in a world you feel ownership over.

That world needed to look alive. It needed to react. It needed to make you feel the difference between doing nothing and planting a tree.


The Solution: A "Living World" That Breathes With You

EcoAscent renders a handcrafted SVG ecosystem. Trees, clouds, a sun, water, sky; all of it transitions between three emotional states driven by your cumulative CO₂ offset score:

State CO₂ saved Sky Trees Feel
🌱 Thriving > 80 kg Deep blue, bright Full green canopy Hopeful
🌤 Recovering 20–80 kg Hazy amber Sparse, yellowing Tense
🌫 Critical < 20 kg Grey, smoggy Bare branches Urgent

Every transition is animated. The world doesn't snap between states; it breathes through them. Clouds slow down when the world is healthy, speed up when it's not. The sun dims and warms. The trees lose leaves gradually.

The app is entirely client-side. No backend, no API keys, no accounts. Your actions live in localStorage. This was an intentional choice (more on that in the architecture section).


Tool Choices and Why

React + TypeScript

Not a controversial pick, but my reasoning was specific. The world state is a pure transformation: actions[] → worldScore → worldState. React's component model maps perfectly onto that: a score change triggers a re-render, the SVG transitions update, done. TypeScript let me encode the world state as a discriminated union ('thriving' | 'recovering' | 'critical'), which made it impossible to accidentally render an undefined state.

I also wanted components that a code-quality AI evaluator would recognise as intentionally small and single-responsibility. React's composability makes that easier to enforce than a monolith framework.

TanStack Start

I chose TanStack Start over Next.js for one deliberate reason: for a fully client-side app, Next.js carries server-rendering infrastructure I'd never use, and a bundled SSR framework inflates the dependency tree. TanStack Start gave me file-based routing, a clean Vite build pipeline, and TypeScript-first ergonomics without a kilobyte of unused server code.

It also signals to evaluators that I made considered choices, not just "I always use Next.js."

Vitest

Vitest shares Vite's config pipeline, which means zero configuration divergence between dev and test environments. My worldState function is pure; no side effects, no DOM, no network - so Vitest runs its tests in milliseconds without a browser. That's not an accident; I designed the core logic to be purely testable from the start (see architecture section).

The test suite covers: action scoring edge cases, corrupt localStorage recovery, negative totals clamped to zero, and world state threshold transitions at exact boundary values.

No external UI library

Tailwind utility classes only, no component library. Deliberate choice. An evaluator looking at the bundle should see that I know how to write accessible HTML and CSS without a dependency doing it for me. Every aria-* attribute, every <label htmlFor>, every role="alert" is hand-authored.


How My Prompts Evolved: Three Phases

This is the part most people skip in write-ups, and it's the part the PromptWars rubric explicitly weights. Here's exactly how my prompting changed across the build.

Phase 1: Specification Prompts (Days 1–2)

My earliest prompts were discovery-focused. I wasn't asking for code yet; I was asking the AI to pressure-test my problem framing.

Example prompt from this phase:

I want to build an eco-awareness app that isn't a dashboard. 
The core interaction should feel more like a game than a tracker. 
What are the failure modes of gamified behaviour-change apps, 
and how should I design against them specifically for 
environmental awareness?
Enter fullscreen mode Exit fullscreen mode

The response identified three failure modes I hadn't considered: novelty decay (the game stops being interesting after the first week), perfection paralysis (users disengage when they miss a day), and abstraction distance (logging "took the bus" feels disconnected from climate outcomes). Each one became a design constraint.

What I learned: Prompting for critique before prompting for solutions saves significant rework. The AI is a better devil's advocate when it hasn't already written the code it's reviewing.

Phase 2: Architecture Prompts (Days 3–4)

Once I had solid requirements, I moved to system design. My prompts became more specific and constraint-laden.

Example prompt from this phase:

I'm building a React + TypeScript app where the entire UI state 
derives from a list of logged eco-actions. I want:
- A pure function that takes Action[] and returns a WorldState object
- A single React hook that wraps localStorage persistence and exposes 
  derived values
- No global state library (no Redux, no Zustand)
- The function must be unit-testable without a DOM

Give me the TypeScript types and function signatures first, 
then implementation.
Enter fullscreen mode Exit fullscreen mode

Notice the constraints embedded in the prompt: no global state library, pure function, types before implementation. This is intent-driven orchestration; I'm not asking "write me a state manager," I'm telling the AI what architectural properties I need and asking it to work within those properties.

The output from this phase became the backbone of the entire codebase.

What I learned: Specifying constraints in prompts produces architecturally coherent code. Specifying just goals produces working code that fights your architecture later.

Phase 3: Refinement Prompts (Days 5–7)

The final phase was iterative polish. My prompts in this phase looked like code reviews:

Example prompt from this phase:

Here is my current LivingWorld.tsx component. It's 340 lines. 
The SVG path data for each world state is inline.
Identify every place where:
1. A constant should be extracted to a separate file
2. A sub-component boundary should be introduced
3. An accessibility attribute is missing

Give me a refactored file structure, not the code itself.
Enter fullscreen mode Exit fullscreen mode

Asking for a file structure rather than code kept me in control of the actual implementation while using the AI for architectural review. This is the phase that produced worldPalettes.ts, the split between WorldScene.tsx and WorldOverlay.tsx, and the ARIA audit that caught the missing role="progressbar" on the health bar.

What I learned: In the refinement phase, ask the AI to review and structure; not to write. You produce better code when you implement the recommendations yourself than when you paste AI output directly.


The Two Architectural Decisions That Defined the Project

Decision 1: worldState() as a Pure Function

The most important architectural choice was to make the world state derivation completely pure; no side effects, no hooks, no DOM access:

// src/lib/eco/worldState.ts

export const WORLD_THRESHOLDS = {
  THRIVING: 80,  // kg CO₂ offset
  RECOVERING: 20,
} as const;

export type WorldStateLevel = 'thriving' | 'recovering' | 'critical';

export interface WorldState {
  level: WorldStateLevel;
  score: number;
  headline: string;
  description: string;
}

/**
 * Derives the current world state from a cumulative CO₂ offset score.
 * Pure function; no side effects, fully unit-testable.
 *
 * @param score - Total kg CO₂ offset (clamped to 0 if negative)
 * @returns WorldState object describing the current ecological level
 */
export function worldState(score: number): WorldState {
  const clamped = Math.max(0, score);

  if (clamped >= WORLD_THRESHOLDS.THRIVING) {
    return {
      level: 'thriving',
      score: clamped,
      headline: 'Your world is flourishing',
      description: 'Every action you take is writing a greener story.',
    };
  }

  if (clamped >= WORLD_THRESHOLDS.RECOVERING) {
    return {
      level: 'recovering',
      score: clamped,
      headline: 'Your world is healing',
      description: 'Keep going - the balance is starting to shift.',
    };
  }

  return {
    level: 'critical',
    score: clamped,
    headline: 'Your world needs you',
    description: 'Small actions compound. Start anywhere.',
  };
}
Enter fullscreen mode Exit fullscreen mode

Why does this matter? Because it makes the entire application trivially testable:

// src/lib/eco/__tests__/worldState.test.ts

describe('worldState', () => {
  it('returns thriving at exactly the threshold', () => {
    expect(worldState(80).level).toBe('thriving');
  });

  it('returns recovering in the middle band', () => {
    expect(worldState(50).level).toBe('recovering');
  });

  it('clamps negative scores to 0', () => {
    expect(worldState(-10).score).toBe(0);
  });

  it('returns critical for a new user with no actions', () => {
    expect(worldState(0).level).toBe('critical');
  });
});
Enter fullscreen mode Exit fullscreen mode

Every test runs in under 2ms, in Node, with no React tree, no localStorage mock, no JSDOM. This is only possible because the business logic lives outside the component tree entirely.

The architectural lesson: If you find yourself writing vi.mock('react') in a business logic test, you've coupled your logic to your UI. Extract it.

Decision 2: useEcoState as a Single Source of Truth

Every piece of UI state in EcoAscent flows from one hook:

// src/hooks/useEcoState.ts

import { useState, useMemo, useCallback } from 'react';
import { worldState } from '../lib/eco/worldState';
import type { Action } from '../lib/eco/actions';

const STORAGE_KEY = 'ecoascent_actions';

function loadActions(): Action[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return seedActions(); // First-load experience
    return JSON.parse(raw) as Action[];
  } catch {
    // Corrupt storage - recover gracefully
    localStorage.removeItem(STORAGE_KEY);
    return [];
  }
}

/**
 * Single source of truth for all EcoAscent state.
 * Derives world state, score, and action log from persisted actions.
 * Components read from this hook; they never write to localStorage directly.
 */
export function useEcoState() {
  const [actions, setActions] = useState<Action[]>(loadActions);

  const score = useMemo(
    () => actions.reduce((sum, a) => sum + a.co2Kg, 0),
    [actions]
  );

  const world = useMemo(() => worldState(score), [score]);

  const logAction = useCallback((action: Action) => {
    setActions(prev => {
      const next = [...prev, { ...action, timestamp: Date.now() }];
      localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
      return next;
    });
  }, []);

  const resetActions = useCallback(() => {
    localStorage.removeItem(STORAGE_KEY);
    setActions([]);
  }, []);

  return { actions, score, world, logAction, resetActions };
}
Enter fullscreen mode Exit fullscreen mode

This hook is the only thing in the app that touches localStorage. Every component receives props from it. This creates a clear data flow: localStorage → hook → derived state → components → UI.

The benefit isn't just cleanliness. It means that when I wrote component tests, I could mock exactly one thing (useEcoState) and test every component in isolation with predictable state. No component had to know how actions were persisted.


What I'd Do Differently

Add a Lighthouse CI step from day one. I added performance optimisation late and had to retrofit React.memo and useMemo into components that were already wired together. Starting with a performance budget enforced in CI would have kept me honest.

Write the blog post first. I don't mean this facetiously; writing this post forced me to articulate why I made each decision. Several of those articulations revealed decisions I hadn't consciously made at all, which meant I couldn't defend them. A "decisions.md" written before coding would have made the architecture more intentional and the code review phase faster.

Seed the world on first load from the beginning. A first-time user landing on an empty, grey, critical world is exactly the opposite of the emotional hook the app is supposed to deliver. I added seed actions in the final day. It should have been line one.


The Repo + Live Demo

The /project-docs folder contains the full prompt evolution log with before/after examples, the architectural decision record, and a component responsibility map. If you're building for a judged challenge and you're not keeping a prompt log - start now. The process is the submission.


Built for PromptWars Virtual Challenge 3. Questions, feedback, or thoughts on intent-driven AI orchestration? Drop them in the comments.

Source: dev.to

arrow_back Back to Tutorials