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,
},
};