baseball-live source

App.tsx

import { useState } from 'react';
import { Block, AppShell, Badge, Column, PlocksProvider, SegmentedControl, Text } from '@plocks/ui';
import { createGame, nextPitch } from './game/engine';
import { GamecastScreen } from './screens/GamecastScreen';
import { ScoresScreen } from './screens/ScoresScreen';
import { StandingsScreen } from './screens/StandingsScreen';
import { BASEBALL_THEME, C } from './theme';

function BaseballApp() {
  const [view, setView] = useState('scores');
  const [game, setGame] = useState(createGame);
  return (
    <AppShell
      header={{ height: 74 }}
      autoLayout
      maxContentWidth={1050}
      centerContent
      padding={0}
      headerContent={
        <Block align="center" justify="space-between" direction="row" flex={1} px={20}>
          <Column gap={0} fullWidth={false}>
            <Text c={C.red} size={10} fw="bold" lts={2}>
              DIAMOND
            </Text>
            <Text c={C.ink} size={20} fw="bold">
              GAME DAY
            </Text>
          </Column>
          <Badge color={C.gold} variant="light">
            DEMO LEAGUE
          </Badge>
        </Block>
      }
    >
      <AppShell.Section grow withScrollArea>
        <Block gap="lg" direction="column" w="100%" p={20} pb={48}>
          <SegmentedControl
            data={[
              { value: 'scores', label: 'Scores' },
              { value: 'gamecast', label: 'Gamecast' },
              { value: 'standings', label: 'Standings' },
            ]}
            value={view}
            onChange={setView}
            color={C.red}
            fullWidth
            accessibilityLabel="App views"
          />
          {view === 'scores' && <ScoresScreen game={game} onOpenGame={() => setView('gamecast')} />}
          {view === 'gamecast' && (
            <GamecastScreen
              game={game}
              onPitch={() => setGame((old) => nextPitch(old))}
              onReset={() => setGame(createGame())}
            />
          )}
          {view === 'standings' && <StandingsScreen />}
        </Block>
      </AppShell.Section>
    </AppShell>
  );
}

export default function App() {
  return (
    <PlocksProvider theme={BASEBALL_THEME}>
      <BaseballApp />
    </PlocksProvider>
  );
}

components/CountDisplay.tsx

import { Block, Column, Row, Text } from '@plocks/ui';
import { C } from '../theme';

function Dots({ count, total, color }: { count: number; total: number; color: string }) {
  return <Row gap={5}>{Array.from({ length: total }, (_, index) => <Block key={index} w={11} h={11} radius="full" bg={index < count ? color : C.line} />)}</Row>;
}
export function CountDisplay({ balls, strikes, outs }: { balls: number; strikes: number; outs: number }) {
  return <Row gap="xl" wrap="wrap" align="center">
    <Column gap={5} fullWidth={false}><Text c={C.muted} size={10} fw="bold">BALLS</Text><Dots count={balls} total={3} color={C.grass} /></Column>
    <Column gap={5} fullWidth={false}><Text c={C.muted} size={10} fw="bold">STRIKES</Text><Dots count={strikes} total={2} color={C.gold} /></Column>
    <Column gap={5} fullWidth={false}><Text c={C.muted} size={10} fw="bold">OUTS</Text><Dots count={outs} total={2} color={C.red} /></Column>
  </Row>;
}

components/FieldDiamond.tsx

import { Block, Column, Row, Text } from '@plocks/ui';
import type { Bases } from '../game/types';
import { C } from '../theme';

function Base({ active, x, y, label }: { active: boolean; x: number; y: number; label: string }) {
  return (
    <Block
      position="absolute"
      left={x}
      top={y}
      w={20}
      h={20}
      bg={active ? C.gold : C.base}
      borderWidth={2}
      borderColor={active ? C.gold : C.ink}
      rotate="45deg"
      accessibilityLabel={`${label} base ${active ? 'occupied' : 'empty'}`}
    />
  );
}

export function FieldDiamond({ bases }: { bases: Bases }) {
  return (
    <Column gap="xs" align="center" fullWidth={false}>
      <Block
        w={260}
        h={205}
        bg={C.grassDeep}
        radius="lg"
        borderWidth={1}
        borderColor={C.line}
        position="relative"
        overflow="hidden"
      >
        <Block
          position="absolute"
          left={71}
          top={37}
          w={118}
          h={118}
          bg={C.grass}
          borderWidth={2}
          borderColor="#6AB68B"
          rotate="45deg"
        />
        <Block position="absolute" left={107} top={72} w={46} h={46} bg="#B48A62" radius="full" />
        <Base active={bases[1]} x={120} y={35} label="Second" />
        <Base active={bases[2]} x={42} y={113} label="Third" />
        <Base active={bases[0]} x={198} y={113} label="First" />
        <Base active={false} x={120} y={168} label="Home" />
        <Block position="absolute" left={117} top={90} w={26} h={26} bg={C.base} radius="full" />
      </Block>
      <Row gap="md" align="center">
        <Text c={C.gold} size={11}>
          ◆
        </Text>
        <Text c={C.muted} size={11}>
          Gold bases have runners on.
        </Text>
      </Row>
    </Column>
  );
}

components/FixtureCard.tsx

import { Block, Badge, Card, Column, Text } from '@plocks/ui';
import { TEAMS, type Fixture } from '../data';
import { C } from '../theme';
import { TeamMark } from './TeamMark';

export function FixtureCard({ fixture }: { fixture: Fixture }) {
  const away = TEAMS[fixture.away],
    home = TEAMS[fixture.home];
  return (
    <Card bg={C.panel} borderColor={C.line} borderWidth={1} radius="lg" padding="lg" w="100%">
      <Column gap="md">
        <Block justify="space-between" align="center" direction="row" w="100%">
          <Text c={C.muted} size={12}>
            {fixture.note}
          </Text>
          <Badge color={fixture.status === 'final' ? C.muted : C.gold} variant="light">
            {fixture.status === 'final' ? 'FINAL' : 'UPCOMING'}
          </Badge>
        </Block>
        <Block align="center" gap="sm" direction="row" w="100%">
          <TeamMark team={away} size={32} />
          <Text c={C.ink} fw="bold" flex={1}>
            {away.name}
          </Text>
          <Text c={C.ink} size={22} fw="bold">
            {fixture.awayScore ?? '–'}
          </Text>
        </Block>
        <Block align="center" gap="sm" direction="row" w="100%">
          <TeamMark team={home} size={32} />
          <Text c={C.ink} fw="bold" flex={1}>
            {home.name}
          </Text>
          <Text c={C.ink} size={22} fw="bold">
            {fixture.homeScore ?? '–'}
          </Text>
        </Block>
      </Column>
    </Card>
  );
}

components/Panel.tsx

import type { ReactNode } from 'react';
import { Block, Card, Column, Row, Text } from '@plocks/ui';
import { C } from '../theme';

export function Panel({
  title,
  eyebrow,
  action,
  children,
}: {
  title?: string;
  eyebrow?: string;
  action?: ReactNode;
  children: ReactNode;
}) {
  return (
    <Card bg={C.panel} borderColor={C.line} borderWidth={1} radius="lg" padding="lg" w="100%">
      <Column gap="md">
        {(title || eyebrow || action) && (
          <Row align="center" justify="space-between" wrap="wrap" gap="sm">
            <Column gap={2} fullWidth={false}>
              {eyebrow && (
                <Text c={C.red} size={10} fw="bold" lts={2}>
                  {eyebrow.toUpperCase()}
                </Text>
              )}
              {title && (
                <Text c={C.ink} size={20} fw="bold">
                  {title}
                </Text>
              )}
            </Column>
            {action}
          </Row>
        )}
        {children}
      </Column>
    </Card>
  );
}

export function Stat({ label, value, accent = false }: { label: string; value: string | number; accent?: boolean }) {
  return (
    <Block gap={2} fullWidth={false} direction="column" miw={72}>
      <Text c={C.muted} size={10} fw="bold" lts={1.2}>
        {label.toUpperCase()}
      </Text>
      <Text c={accent ? C.gold : C.ink} size={20} fw="bold">
        {value}
      </Text>
    </Block>
  );
}

components/Scoreboard.tsx

import { Block, Badge, Column, Divider, Text } from '@plocks/ui';
import { AWAY, HOME } from '../data';
import { halfLabel } from '../game/engine';
import type { GameState } from '../game/types';
import { C } from '../theme';
import { Panel } from './Panel';
import { TeamMark } from './TeamMark';

export function Scoreboard({ game, compact = false }: { game: GameState; compact?: boolean }) {
  const status = game.status === 'final' ? 'FINAL' : `${halfLabel(game.half).toUpperCase()} ${game.inning}`;
  return (
    <Panel
      eyebrow="Demo league · Game 01"
      action={
        <Badge color={game.status === 'live' ? C.red : C.muted} variant="light">
          {status}
        </Badge>
      }
    >
      <Column gap="sm">
        <TeamRow team={AWAY} score={game.away} record={`${AWAY.wins}-${AWAY.losses}`} compact={compact} />
        <Divider color={C.line} />
        <TeamRow team={HOME} score={game.home} record={`${HOME.wins}-${HOME.losses}`} compact={compact} />
      </Column>
    </Panel>
  );
}

function TeamRow({
  team,
  score,
  record,
  compact,
}: {
  team: typeof AWAY;
  score: number;
  record: string;
  compact: boolean;
}) {
  return (
    <Block align="center" gap="md" direction="row" w="100%">
      <TeamMark team={team} size={compact ? 36 : 44} />
      <Block gap={0} direction="column" flex={1}>
        <Text c={C.ink} size={compact ? 15 : 19} fw="bold">
          {team.name}
        </Text>
        <Text c={C.muted} size={11}>
          {record} · {team.code}
        </Text>
      </Block>
      <Text c={C.ink} size={compact ? 27 : 35} fw="bold">
        {score}
      </Text>
    </Block>
  );
}

components/TeamMark.tsx

import { Block, Text } from '@plocks/ui';
import type { Team } from '../data';
import { C } from '../theme';

export function TeamMark({ team, size = 42 }: { team: Team; size?: number }) {
  return <Block w={size} h={size} radius="full" bg={C.panelRaised} borderWidth={2} borderColor={team.color}
    flex align="center" justify="center" accessibilityLabel={team.name}>
    <Text c={team.color} size={size < 36 ? 10 : 13} fw="bold">{team.code}</Text>
  </Block>;
}

data.ts

export interface Team { code: string; name: string; short: string; color: string; wins: number; losses: number; }
export const TEAMS: Record<string, Team> = {
  HBR: { code: 'HBR', name: 'Harbor Hawks', short: 'Hawks', color: '#71C2DC', wins: 43, losses: 28 },
  DSR: { code: 'DSR', name: 'Desert Solars', short: 'Solars', color: '#F1BE64', wins: 39, losses: 32 },
  MNT: { code: 'MNT', name: 'Mountain Bears', short: 'Bears', color: '#A7B7E2', wins: 37, losses: 34 },
  RIV: { code: 'RIV', name: 'River Otters', short: 'Otters', color: '#8DCCA9', wins: 34, losses: 37 },
  CTY: { code: 'CTY', name: 'City Comets', short: 'Comets', color: '#E89AB2', wins: 41, losses: 30 },
  BAY: { code: 'BAY', name: 'Bay Waves', short: 'Waves', color: '#8DBEDB', wins: 31, losses: 40 },
};
export const AWAY = TEAMS.HBR;
export const HOME = TEAMS.DSR;
export const AWAY_LINEUP = ['M. Vega', 'J. Park', 'A. Brooks', 'T. Morgan', 'C. Reed', 'L. Chen', 'N. Cruz', 'E. Price', 'S. Ellis'];
export const HOME_LINEUP = ['R. Flores', 'D. Hale', 'K. Bennett', 'I. Stone', 'P. Malik', 'W. Torres', 'B. Lane', 'O. Kim', 'F. Walsh'];

export interface Fixture { id: string; away: string; home: string; status: 'final' | 'scheduled'; awayScore?: number; homeScore?: number; note: string; }
export const EARLIER: Fixture[] = [
  { id: 'early-1', away: 'CTY', home: 'BAY', status: 'final', awayScore: 5, homeScore: 2, note: 'Final' },
  { id: 'early-2', away: 'RIV', home: 'MNT', status: 'final', awayScore: 3, homeScore: 4, note: 'Final · 10 innings' },
];
export const DEMO_SLATE: Fixture[] = [
  { id: 'today-1', away: 'CTY', home: 'BAY', status: 'final', awayScore: 6, homeScore: 1, note: 'Final' },
  { id: 'today-2', away: 'MNT', home: 'RIV', status: 'scheduled', note: '7:40 PM · Mountain Park' },
];
export const NEXT: Fixture[] = [
  { id: 'next-1', away: 'DSR', home: 'MNT', status: 'scheduled', note: '1:10 PM · Summit Field' },
  { id: 'next-2', away: 'BAY', home: 'HBR', status: 'scheduled', note: '6:35 PM · Harbor Park' },
];

game/engine.ts

import type { Bases, GameState, Half, PlayEvent, Side } from './types';

export const battingSide = (game: GameState): Side => game.half === 'top' ? 'away' : 'home';
export const halfLabel = (half: Half) => half === 'top' ? 'Top' : 'Bottom';

export function createGame(): GameState {
  return {
    inning: 7, half: 'top', status: 'live', away: 3, home: 2,
    hits: { away: 7, home: 5 }, errors: { away: 0, home: 1 },
    innings: { away: [1, 0, 0, 2, 0, 0, 0], home: [0, 1, 0, 0, 1, 0] },
    balls: 1, strikes: 1, outs: 1, bases: [false, true, false],
    batter: { away: 3, home: 4 }, pitchCount: 95,
    events: [{ id: 95, inning: 7, half: 'top', title: 'Game in progress', detail: 'Hawks have a runner on second with one out.', away: 3, home: 2 }],
  };
}

function log(game: GameState, title: string, detail: string, occurrence?: Pick<GameState, 'inning' | 'half'>): GameState {
  const event: PlayEvent = { id: game.pitchCount, inning: occurrence?.inning ?? game.inning, half: occurrence?.half ?? game.half, title, detail, away: game.away, home: game.home };
  return { ...game, events: [event, ...game.events].slice(0, 80) };
}

function nextBatter(game: GameState, side: Side): Record<Side, number> {
  return { ...game.batter, [side]: (game.batter[side] + 1) % 9 };
}

function withRuns(game: GameState, side: Side, runs: number): GameState {
  if (!runs) return game;
  const row = [...game.innings[side]];
  while (row.length < game.inning) row.push(0);
  row[game.inning - 1] += runs;
  const updated: GameState = { ...game, [side]: game[side] + runs, innings: { ...game.innings, [side]: row } };
  if (game.half === 'bottom' && game.inning >= 9 && updated.home > updated.away) updated.status = 'final';
  return updated;
}

function finishAtBat(game: GameState, bases: Bases, runs: number, title: string, detail: string, hit: boolean): GameState {
  const side = battingSide(game);
  const scored = withRuns(game, side, runs);
  const updated: GameState = { ...scored, bases, balls: 0, strikes: 0, batter: nextBatter(game, side),
    hits: hit ? { ...scored.hits, [side]: scored.hits[side] + 1 } : scored.hits };
  return log(updated, title, runs ? `${detail} ${runs} run${runs === 1 ? '' : 's'} score${runs === 1 ? 's' : ''}.` : detail);
}

function hit(game: GameState, basesTaken: 1 | 2 | 4, title: string): GameState {
  const next: Bases = [false, false, false];
  let runs = 0;
  for (let base = 2; base >= 0; base--) {
    if (!game.bases[base]) continue;
    if (base + basesTaken >= 3) runs++;
    else next[base + basesTaken] = true;
  }
  if (basesTaken === 4) runs++;
  else next[basesTaken - 1] = true;
  return finishAtBat(game, next, runs, title, basesTaken === 4 ? 'The ball clears the fence.' : 'The ball is in play.', true);
}

function walk(game: GameState): GameState {
  const [first, second, third] = game.bases;
  const runs = first && second && third ? 1 : 0;
  return finishAtBat(game, [true, first || second, third || (first && second)], runs, 'Walk', 'Ball four puts the batter on first.', false);
}

function out(game: GameState, title: string): GameState {
  const side = battingSide(game);
  if (game.outs < 2) return log({ ...game, outs: game.outs + 1, balls: 0, strikes: 0, batter: nextBatter(game, side) }, title, `${game.outs + 1} out${game.outs ? 's' : ''} in the inning.`);
  const afterTop = game.half === 'top';
  const final = afterTop ? game.inning >= 9 && game.home > game.away : game.inning >= 9 && game.home !== game.away;
  const nextInning = afterTop || final ? game.inning : game.inning + 1;
  const nextSide: Side = afterTop ? 'home' : 'away';
  const nextLine = [...game.innings[nextSide]];
  if (!final) while (nextLine.length < nextInning) nextLine.push(0);
  return log({ ...game, status: final ? 'final' : 'live', inning: afterTop || final ? game.inning : game.inning + 1,
    half: final ? game.half : afterTop ? 'bottom' : 'top', outs: 0, balls: 0, strikes: 0,
    bases: [false, false, false], batter: nextBatter(game, side),
    innings: final ? game.innings : { ...game.innings, [nextSide]: nextLine } }, title,
    final ? 'Side retired. The game is final.' : `Side retired. ${afterTop ? 'Bottom' : 'Top'} of the ${afterTop ? game.inning : game.inning + 1}${ordinal(afterTop ? game.inning : game.inning + 1)} is next.`, game);
}

function ordinal(n: number): string { return n % 100 >= 11 && n % 100 <= 13 ? 'th' : n % 10 === 1 ? 'st' : n % 10 === 2 ? 'nd' : n % 10 === 3 ? 'rd' : 'th'; }

/** A random value can be injected to reproduce a pitch in tests. */
export function nextPitch(state: GameState, random: () => number = Math.random): GameState {
  if (state.status === 'final') return state;
  const game = { ...state, pitchCount: state.pitchCount + 1 };
  const roll = random();
  if (roll < .25) return game.balls === 3 ? walk(game) : log({ ...game, balls: game.balls + 1 }, 'Ball', `Count ${game.balls + 1}–${game.strikes}.`);
  if (roll < .50) return game.strikes === 2 ? out(game, 'Strikeout') : log({ ...game, strikes: game.strikes + 1 }, 'Called strike', `Count ${game.balls}–${game.strikes + 1}.`);
  if (roll < .60) return log({ ...game, strikes: Math.min(2, game.strikes + 1) }, 'Foul ball', 'The batter stays alive.');
  if (roll < .74) return out(game, 'Fly out');
  if (roll < .88) return hit(game, 1, 'Single');
  if (roll < .96) return hit(game, 2, 'Double');
  return hit(game, 4, 'Home run');
}

game/types.ts

export type Side = 'away' | 'home';
export type Half = 'top' | 'bottom';
export type Bases = [boolean, boolean, boolean];

export interface PlayEvent {
  id: number;
  inning: number;
  half: Half;
  title: string;
  detail: string;
  away: number;
  home: number;
}

export interface GameState {
  inning: number;
  half: Half;
  status: 'live' | 'final';
  away: number;
  home: number;
  hits: Record<Side, number>;
  errors: Record<Side, number>;
  innings: Record<Side, number[]>;
  balls: number;
  strikes: number;
  outs: number;
  bases: Bases;
  batter: Record<Side, number>;
  pitchCount: number;
  events: PlayEvent[];
}

index.ts

import { registerRootComponent } from 'expo';

import App from './App';

registerRootComponent(App);

screens/GamecastScreen.tsx

import { useState } from 'react';
import { Badge, Block, Button, Column, Divider, Row, Scroller, SegmentedControl, Text, Title } from '@plocks/ui';
import { AWAY, AWAY_LINEUP, HOME, HOME_LINEUP } from '../data';
import { CountDisplay } from '../components/CountDisplay';
import { FieldDiamond } from '../components/FieldDiamond';
import { Panel, Stat } from '../components/Panel';
import { Scoreboard } from '../components/Scoreboard';
import { battingSide, halfLabel } from '../game/engine';
import type { GameState } from '../game/types';
import { C } from '../theme';

export function GamecastScreen({
  game,
  onPitch,
  onReset,
}: {
  game: GameState;
  onPitch: () => void;
  onReset: () => void;
}) {
  const [view, setView] = useState('field');
  const side = battingSide(game);
  const batter = side === 'away' ? AWAY_LINEUP[game.batter.away] : HOME_LINEUP[game.batter.home];
  const last = game.events[0];
  return (
    <Column gap="lg">
      <Row align="center" justify="space-between" wrap="wrap" gap="sm">
        <Column gap="xs" fullWidth={false}>
          <Text c={C.red} size={11} fw="bold" lts={2}>
            GAME CENTER
          </Text>
          <Title order={1} c={C.ink}>
            Gamecast
          </Title>
        </Column>
        <Badge color={C.red} variant="light">
          {game.status === 'live' ? 'LIVE DEMO' : 'FINAL'}
        </Badge>
      </Row>
      <Scoreboard game={game} />
      <SegmentedControl
        data={[
          { value: 'field', label: 'Field' },
          { value: 'plays', label: 'Play by play' },
          { value: 'box', label: 'Box score' },
        ]}
        value={view}
        onChange={setView}
        color={C.red}
        fullWidth
      />
      {view === 'field' && (
        <>
          <Panel
            eyebrow="At a glance"
            title={
              game.status === 'live'
                ? `${halfLabel(game.half)} ${game.inning} · ${game.outs} out${game.outs === 1 ? '' : 's'}`
                : 'Game complete'
            }
          >
            <Row wrap="wrap" gap="xl" align="center" justify="space-around">
              <FieldDiamond bases={game.bases} />
              <Block gap="lg" fullWidth={false} direction="column" miw={180}>
                <CountDisplay balls={game.balls} strikes={game.strikes} outs={game.outs} />
                <Column gap={3}>
                  <Text c={C.muted} size={10} fw="bold">
                    AT BAT
                  </Text>
                  <Text c={C.ink} size={21} fw="bold">
                    {game.status === 'final' ? '—' : batter}
                  </Text>
                  <Text c={C.muted} size={12}>
                    {side === 'away' ? AWAY.name : HOME.name}
                  </Text>
                </Column>
                <Stat label="Pitches in game" value={game.pitchCount} />
              </Block>
            </Row>
          </Panel>
          <Panel eyebrow="Latest play" title={last.title}>
            <Text c={C.muted}>{last.detail}</Text>
          </Panel>
          <Row wrap="wrap" gap="sm">
            <Button
              title="Next pitch"
              color={C.red}
              variant="filled"
              onPress={onPitch}
              disabled={game.status === 'final'}
            />
            <Button title="Reset simulation" color={C.red} variant="outline" onPress={onReset} />
          </Row>
        </>
      )}
      {view === 'plays' && <PlayLog game={game} />}
      {view === 'box' && <BoxScore game={game} />}
      <Text c={C.muted} size={11}>
        Gamecast advances one simulated pitch per tap. It is not connected to a live sports feed.
      </Text>
    </Column>
  );
}

function PlayLog({ game }: { game: GameState }) {
  return (
    <Panel eyebrow={`${game.events.length} recent events`} title="Play by play">
      {game.events.map((event, index) => (
        <Block key={`${event.id}-${index}`} gap="xs" direction="column" py={10}>
          <Row justify="space-between" align="center" wrap="wrap">
            <Text c={C.red} size={10} fw="bold">
              {halfLabel(event.half).toUpperCase()} {event.inning} · PITCH {event.id}
            </Text>
            <Text c={C.muted} size={11}>
              {AWAY.code} {event.away} – {HOME.code} {event.home}
            </Text>
          </Row>
          <Text c={C.ink} fw="bold">
            {event.title}
          </Text>
          <Text c={C.muted} size={13}>
            {event.detail}
          </Text>
          {index < game.events.length - 1 && <Divider color={C.line} />}
        </Block>
      ))}
    </Panel>
  );
}

function BoxScore({ game }: { game: GameState }) {
  const count = Math.max(9, game.inning);
  const labels = Array.from({ length: count }, (_, i) => i + 1);
  return (
    <Column gap="lg">
      <Panel eyebrow="Runs by inning" title="Line score">
        <Scroller>
          <Block w={Math.max(590, 126 + count * 47)}>
            <Row gap={0}>
              <Text c={C.muted} size={11} w={115}>
                TEAM
              </Text>
              {labels.map((n) => (
                <Text key={n} c={C.muted} size={11} ta="center" w={47}>
                  {n}
                </Text>
              ))}
            </Row>
            <LineRow code={AWAY.code} values={game.innings.away} count={count} />
            <LineRow code={HOME.code} values={game.innings.home} count={count} />
          </Block>
        </Scroller>
      </Panel>
      <Panel eyebrow="Team totals" title="The numbers">
        <Block direction="row" w="100%">
          <Text c={C.muted} flex={1}>
            CLUB
          </Text>
          <Text c={C.muted} ta="right" w={42}>
            R
          </Text>
          <Text c={C.muted} ta="right" w={42}>
            H
          </Text>
          <Text c={C.muted} ta="right" w={42}>
            E
          </Text>
        </Block>
        <Totals code={AWAY.code} runs={game.away} hits={game.hits.away} errors={game.errors.away} />
        <Totals code={HOME.code} runs={game.home} hits={game.hits.home} errors={game.errors.home} />
      </Panel>
      <Panel eyebrow="Game context" title="Matchup">
        <Row wrap="wrap" gap="xl">
          <Stat label="Inning" value={`${halfLabel(game.half)} ${game.inning}`} />
          <Stat label="Total pitches" value={game.pitchCount} />
          <Stat label="Half-inning outs" value={game.outs} />
        </Row>
      </Panel>
    </Column>
  );
}

function LineRow({ code, values, count }: { code: string; values: number[]; count: number }) {
  return (
    <Block gap={0} direction="row" py={9} borderTopWidth={1} borderTopColor={C.line}>
      <Text c={C.ink} fw="bold" w={115}>
        {code}
      </Text>
      {Array.from({ length: count }, (_, i) => (
        <Text key={i} c={values[i] === undefined ? C.muted : C.ink} ta="center" w={47}>
          {values[i] ?? '–'}
        </Text>
      ))}
    </Block>
  );
}

function Totals({ code, runs, hits, errors }: { code: string; runs: number; hits: number; errors: number }) {
  return (
    <Block direction="row" w="100%" py={8} borderTopWidth={1} borderTopColor={C.line}>
      <Text c={C.ink} fw="bold" flex={1}>
        {code}
      </Text>
      <Text c={C.ink} ta="right" w={42}>
        {runs}
      </Text>
      <Text c={C.ink} ta="right" w={42}>
        {hits}
      </Text>
      <Text c={C.ink} ta="right" w={42}>
        {errors}
      </Text>
    </Block>
  );
}

screens/ScoresScreen.tsx

import { useState } from 'react';
import { Button, Column, Row, SegmentedControl, Text, Title } from '@plocks/ui';
import { DEMO_SLATE, EARLIER, NEXT } from '../data';
import type { GameState } from '../game/types';
import { C } from '../theme';
import { FixtureCard } from '../components/FixtureCard';
import { Scoreboard } from '../components/Scoreboard';

export function ScoresScreen({ game, onOpenGame }: { game: GameState; onOpenGame: () => void }) {
  const [slate, setSlate] = useState('demo');
  const fixtures = slate === 'earlier' ? EARLIER : slate === 'next' ? NEXT : DEMO_SLATE;
  return (
    <Column gap="lg">
      <Column gap="xs">
        <Text c={C.red} size={11} fw="bold" lts={2}>
          SCOREBOARD
        </Text>
        <Title order={1} c={C.ink}>
          Games
        </Title>
        <Text c={C.muted}>Follow the demo matchup and browse the fictional league.</Text>
      </Column>
      <SegmentedControl
        data={[
          { value: 'earlier', label: 'Earlier' },
          { value: 'demo', label: 'Demo slate' },
          { value: 'next', label: 'Next' },
        ]}
        value={slate}
        onChange={setSlate}
        color={C.red}
        fullWidth
      />
      {slate === 'demo' && (
        <Column gap="sm">
          <Row justify="space-between" align="center">
            <Text c={C.ink} size={18} fw="bold">
              Featured matchup
            </Text>
            <Text c={C.red} fw="bold" size={11}>
              {game.status === 'live' ? '● LIVE SIMULATION' : 'FINAL'}
            </Text>
          </Row>
          <Scoreboard game={game} compact />
          <Row wrap="wrap" align="center" justify="space-between">
            <Text c={C.muted} size={12}>
              Harbor Park · fictional teams
            </Text>
            <Button title="Open Gamecast" size="sm" color={C.red} variant="filled" onPress={onOpenGame} />
          </Row>
        </Column>
      )}
      <Column gap="md">
        <Text c={C.ink} size={18} fw="bold">
          {slate === 'demo' ? 'Around the league' : slate === 'earlier' ? 'Earlier results' : 'Next games'}
        </Text>
        {fixtures.map((fixture) => (
          <FixtureCard key={fixture.id} fixture={fixture} />
        ))}
      </Column>
      <Text c={C.muted} size={11}>
        All teams, results, and schedules shown here are sample data.
      </Text>
    </Column>
  );
}

screens/StandingsScreen.tsx

import { useState } from 'react';
import { Block, Column, SegmentedControl, Text, Title } from '@plocks/ui';
import { TEAMS, type Team } from '../data';
import { C } from '../theme';
import { Panel } from '../components/Panel';
import { TeamMark } from '../components/TeamMark';

const DIVISIONS: Record<string, Team[]> = {
  Coast: [TEAMS.HBR, TEAMS.CTY, TEAMS.BAY],
  Inland: [TEAMS.DSR, TEAMS.MNT, TEAMS.RIV],
};
export function StandingsScreen() {
  const [division, setDivision] = useState('Coast');
  const teams = [...DIVISIONS[division]].sort((a, b) => b.wins - a.wins);
  const leader = teams[0];
  return (
    <Column gap="lg">
      <Column gap="xs">
        <Text c={C.red} size={11} fw="bold" lts={2}>
          DEMO LEAGUE
        </Text>
        <Title order={1} c={C.ink}>
          Standings
        </Title>
        <Text c={C.muted}>A sample league table to round out the game day experience.</Text>
      </Column>
      <SegmentedControl data={['Coast', 'Inland']} value={division} onChange={setDivision} color={C.red} fullWidth />
      <Panel eyebrow={`${division} division`} title="League table">
        <Block align="center" gap="sm" direction="row" w="100%">
          <Text c={C.muted} size={10} w={32}>
            #
          </Text>
          <Text c={C.muted} size={10} flex={1}>
            CLUB
          </Text>
          <Text c={C.muted} size={10} w={37} ta="right">
            W
          </Text>
          <Text c={C.muted} size={10} w={37} ta="right">
            L
          </Text>
          <Text c={C.muted} size={10} w={42} ta="right">
            GB
          </Text>
        </Block>
        {teams.map((team, index) => {
          const gamesBack = (leader.wins - team.wins + (team.losses - leader.losses)) / 2;
          return (
            <Block
              key={team.code}
              align="center"
              gap="sm"
              direction="row"
              w="100%"
              py={10}
              borderTopWidth={1}
              borderTopColor={C.line}
            >
              <Text c={C.muted} size={13} w={32}>
                {index + 1}
              </Text>
              <TeamMark team={team} size={32} />
              <Text c={C.ink} fw="bold" size={13} flex={1}>
                {team.name}
              </Text>
              <Text c={C.ink} size={13} w={37} ta="right">
                {team.wins}
              </Text>
              <Text c={C.ink} size={13} w={37} ta="right">
                {team.losses}
              </Text>
              <Text c={C.muted} size={13} w={42} ta="right">
                {index === 0 ? '—' : gamesBack.toFixed(1)}
              </Text>
            </Block>
          );
        })}
      </Panel>
      <Text c={C.muted} size={11}>
        Records are illustrative and do not update with the pitch simulation.
      </Text>
    </Column>
  );
}

theme.ts

export const C = {
  page: '#0B1720',
  panel: '#14252F',
  panelRaised: '#1A303B',
  line: '#2B4651',
  ink: '#F7F6EE',
  muted: '#9CB3BB',
  red: '#F36A55',
  gold: '#F1BE64',
  grass: '#27684D',
  grassDeep: '#184936',
  base: '#F7F2D8',
} as const;

export const BASEBALL_THEME = {
  colorScheme: 'dark' as const,
  primaryColor: C.red,
  backgrounds: {
    base: C.page,
    subtle: C.panel,
    surface: C.panel,
    elevated: C.panelRaised,
    border: C.line,
    borderStrong: C.muted,
  },
};