// PRETEXT BREAKER — arcade-style breakout game where bricks are words. // Inspired by the pretextjs.dev Pretext Breaker demo. // Each brick's width = natural width of its word at the game font (measureNaturalWidth). // Polished UI matching the PinchToZoom design language. import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { View, Text, StyleSheet, useWindowDimensions, PanResponder, Pressable } from 'react-native' import { prepareWithSegments, measureNaturalWidth, layoutColumn, type CircleObstacle, type RectObstacle, } from 'expo-pretext' const BRICK_STYLE = { fontFamily: 'Menlo', fontSize: 13, lineHeight: 20 } const PROSE_STYLE = { fontFamily: 'Menlo', fontSize: 10, lineHeight: 14 } const PROSE_LH = 14 // Meaningful sentence — each word becomes a brick const SENTENCE = 'PRETEXT TURNS MOTION INTO LANGUAGE AND LETS EVERY MEASURED WORD SWING INTO PLACE WHILE YOU BREAK THE APART' const WORDS = SENTENCE.split(' ') // Arcade palette — cycles per brick const BRICK_COLORS = [ '#e0893e', // orange '#d9a441', // yellow '#9c6eba', // purple '#4a9e5d', // green '#3d8bbf', // cyan '#c44e5a', // red ] // Background prose text — reflows around the ball and bricks via layoutColumn(). // This is the core dogfood of expo-pretext: obstacle layout at 60fps with a // moving circular obstacle (the ball) carving a hole in real text. const BG_PROSE = `The layout engine measures every segment once then lays out lines in pure arithmetic no reflows no DOM reads no thrashing The ball is a cursor the bricks are words and the words are geometry When a word breaks its measured width returns to the pool When the paddle catches a power word the rules shift for a moment slow motion multi ball wider guard The sentence at the top of the arena describes a philosophy once you measure the text with the same engine that renders it you stop fighting the browser and start choreographing it Pretext prepares once and layouts many times The native TextKit measurement is pixel accurate The JavaScript line break algorithm runs in microseconds The ball traces a path through words and prose and the text simply flows around it `.repeat(4) const CONTAINER_PADDING = 16 const ARENA_PAD = 12 type Brick = { id: number text: string x: number y: number w: number h: number color: string alive: boolean // Physics: when the ball hits a brick it enters "falling" state — it // detaches from the grid and becomes a rigid body with gravity, bouncing // off walls and the paddle just like the ball. Still acts as a text obstacle. falling: boolean vx: number vy: number } type Ball = { x: number; y: number; vx: number; vy: number; r: number } // Physics constants const GRAVITY = 0.35 const BRICK_BOUNCE_DAMPING = 0.72 const BRICK_FRICTION = 0.985 export function BreakoutTextDemo() { const { width } = useWindowDimensions() const stageW = width - CONTAINER_PADDING * 2 const arenaH = 420 const BRICK_H = 26 const BRICK_V_GAP = 4 const BRICK_H_GAP = 6 // Prepare the background prose once — it gets re-laid-out every frame as // the ball moves. This is the whole point of the demo. const preparedProse = useMemo(() => prepareWithSegments(BG_PROSE, PROSE_STYLE), []) // Build bricks once per width const initialBricks = useMemo(() => { const list: Brick[] = [] const widths = WORDS.map(w => { const prepared = prepareWithSegments(w, BRICK_STYLE) return measureNaturalWidth(prepared) + 18 // padding inside the brick }) let x = ARENA_PAD let y = ARENA_PAD for (let i = 0; i < WORDS.length; i++) { const w = widths[i]! if (x + w > stageW - ARENA_PAD) { x = ARENA_PAD y += BRICK_H + BRICK_V_GAP } list.push({ id: i, text: WORDS[i]!, x, y, w, h: BRICK_H, color: BRICK_COLORS[i % BRICK_COLORS.length]!, alive: true, falling: false, vx: 0, vy: 0, }) x += w + BRICK_H_GAP } return list }, [stageW]) const [bricks, setBricks] = useState(initialBricks) const [score, setScore] = useState(0) const [lives, setLives] = useState(3) const [level] = useState(1) const [gameState, setGameState] = useState<'running' | 'gameover' | 'won'>('running') // Paddle & ball use arena-local coordinates (0..stageW horizontally, 0..arenaH vertically) const [paddle, setPaddle] = useState({ x: stageW / 2 - 50, y: arenaH - 22, w: 100, h: 10 }) const paddleRef = useRef(paddle) paddleRef.current = paddle const [ball, setBall] = useState({ x: stageW / 2, y: arenaH - 60, vx: 2.6, vy: -2.6, r: 6 }) const [powerLabel, setPowerLabel] = useState(null) const powerExpiresRef = useRef(0) const pan = useMemo(() => PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: (e) => { // Use absolute pageX to avoid locationX oscillating between nested hit targets // (bricks, ball, paddle). Subtract the arena's left offset (container padding). const lx = e.nativeEvent.pageX - CONTAINER_PADDING setPaddle(p => ({ ...p, x: Math.max(ARENA_PAD, Math.min(stageW - p.w - ARENA_PAD, lx - p.w / 2)) })) }, onPanResponderMove: (_e, gestureState) => { const lx = gestureState.moveX - CONTAINER_PADDING setPaddle(p => ({ ...p, x: Math.max(ARENA_PAD, Math.min(stageW - p.w - ARENA_PAD, lx - p.w / 2)) })) }, onPanResponderTerminationRequest: () => false, }), [stageW]) useEffect(() => { if (gameState !== 'running') return const timer = setInterval(() => { if (powerExpiresRef.current && Date.now() > powerExpiresRef.current) { powerExpiresRef.current = 0 setPowerLabel(null) } // Update falling bricks (gravity + wall/paddle bounces + off-screen cleanup) setBricks(prev => { let anyChanged = false const next: Brick[] = [] for (const br of prev) { if (!br.falling) { next.push(br) continue } let { x, y, vx, vy } = br vy += GRAVITY vx *= BRICK_FRICTION x += vx y += vy // Wall bounces if (x < ARENA_PAD) { x = ARENA_PAD vx = -vx * BRICK_BOUNCE_DAMPING } if (x + br.w > stageW - ARENA_PAD) { x = stageW - ARENA_PAD - br.w vx = -vx * BRICK_BOUNCE_DAMPING } // Paddle collision (rect-vs-rect overlap on paddle top edge) const pd = paddleRef.current if ( y + br.h >= pd.y && y + br.h <= pd.y + pd.h + 8 && x + br.w > pd.x && x < pd.x + pd.w && vy > 0 ) { y = pd.y - br.h vy = -Math.abs(vy) * BRICK_BOUNCE_DAMPING // Horizontal kick from where the brick landed on the paddle const rel = ((x + br.w / 2) - (pd.x + pd.w / 2)) / (pd.w / 2) vx += rel * 2.2 } // Remove when fully below arena floor if (y > arenaH + 40) { anyChanged = true continue } anyChanged = true next.push({ ...br, x, y, vx, vy }) } return anyChanged ? next : prev }) setBall(b => { let { x, y, vx, vy, r } = b const slow = powerLabel === 'SLOW' ? 0.55 : 1 x += vx * slow y += vy * slow // Wall collisions (arena bounds) if (x - r < ARENA_PAD) { x = ARENA_PAD + r; vx = -vx } if (x + r > stageW - ARENA_PAD) { x = stageW - ARENA_PAD - r; vx = -vx } if (y - r < ARENA_PAD) { y = ARENA_PAD + r; vy = -vy } // Paddle collision const p = paddleRef.current if (y + r >= p.y && y + r <= p.y + p.h + 6 && x >= p.x && x <= p.x + p.w && vy > 0) { vy = -Math.abs(vy) const rel = (x - (p.x + p.w / 2)) / (p.w / 2) vx = rel * 3.5 } // Ball below arena → lose life if (y > arenaH) { setLives(l => { const next = Math.max(0, l - 1) if (next === 0) setGameState('gameover') return next }) return { x: stageW / 2, y: arenaH - 60, vx: 2.6, vy: -2.6, r } } // Brick collisions — transition from alive to falling instead of destroying setBricks(prev => { let hit = false const next = prev.map(br => { if (!br.alive || br.falling) return br if ( x + r >= br.x && x - r <= br.x + br.w && y + r >= br.y && y - r <= br.y + br.h ) { hit = true vy = -vy if (Math.random() < 0.18) { const powers = ['SLOW', 'MULTI', 'EXPAND'] as const const power = powers[Math.floor(Math.random() * powers.length)]! setPowerLabel(power) powerExpiresRef.current = Date.now() + 5000 if (power === 'EXPAND') { setPaddle(pp => ({ ...pp, w: 160 })) setTimeout(() => setPaddle(pp => ({ ...pp, w: 100 })), 5000) } } // Transfer some of the ball's velocity to the brick, plus an upward pop return { ...br, alive: false, falling: true, vx: vx * 0.4 + (Math.random() - 0.5) * 1.2, vy: -2.2, } } return br }) if (hit) { setScore(s => s + 60 + level * 10) if (next.every(b => !b.alive)) setGameState('won') } return hit ? next : prev }) return { x, y, vx, vy, r } }) }, 16) return () => clearInterval(timer) }, [stageW, arenaH, level, powerLabel, gameState]) const aliveCount = bricks.filter(b => b.alive).length // Compute background prose reflowed around the ball, live bricks, falling // bricks, and paddle. Runs every frame — layoutColumn() is pure arithmetic // at ~0.0002ms per line so this is effectively free. const proseLines = useMemo(() => { const circles: CircleObstacle[] = [ { cx: ball.x, cy: ball.y, r: ball.r + 6, hPad: 4, vPad: 2 }, ] const rects: RectObstacle[] = [ // Every brick that is still in the arena — both alive (grid) and falling ...bricks .filter(b => b.alive || b.falling) .map(b => ({ x: b.x, y: b.y, w: b.w, h: b.h })), // Paddle as obstacle { x: paddle.x, y: paddle.y, w: paddle.w, h: paddle.h }, ] return layoutColumn( preparedProse, { segmentIndex: 0, graphemeIndex: 0 }, { x: ARENA_PAD, y: ARENA_PAD, width: stageW - ARENA_PAD * 2, height: arenaH - ARENA_PAD * 2 }, PROSE_LH, circles, rects, ).lines }, [preparedProse, ball, bricks, paddle, stageW, arenaH]) const reset = useCallback(() => { setBricks(initialBricks) setScore(0) setLives(3) setBall({ x: stageW / 2, y: arenaH - 60, vx: 2.6, vy: -2.6, r: 6 }) setPaddle({ x: stageW / 2 - 50, y: arenaH - 22, w: 100, h: 10 }) setPowerLabel(null) powerExpiresRef.current = 0 setGameState('running') }, [initialBricks, stageW, arenaH]) return ( {/* Top header card */} PRETEXT BREAKER {gameState === 'running' ? 'LIVE' : gameState === 'won' ? 'WON' : 'OVER'} {/* Metrics grid — matches PinchToZoom style */} SCORE {String(score).padStart(4, '0')} LIVES {'♥'.repeat(lives) || '—'} LEVEL {String(level).padStart(2, '0')} LEFT {aliveCount} Drag anywhere in the arena to move the paddle. Break every word. {/* Arena */} {/* Background prose — reflows around ball + bricks + paddle via layoutColumn() */} {proseLines.map((line, i) => ( {line.text} ))} {/* Word bricks — both alive (on grid) and falling (free bodies) */} {bricks.map(br => (br.alive || br.falling) && ( {br.text} ))} {/* Ball */} {/* Paddle */} {/* Power badge */} {powerLabel && ( ⚡ {powerLabel} )} {/* Game over / won overlay */} {gameState !== 'running' && ( {gameState === 'won' ? '🎉 YOU WIN' : 'GAME OVER'} Score {String(score).padStart(4, '0')} PLAY AGAIN )} {/* Bottom action bar */} measureNaturalWidth() · brick widths = real text widths RESET ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#0a0a0c', paddingHorizontal: CONTAINER_PADDING, paddingTop: CONTAINER_PADDING, }, // Header card headerCard: { backgroundColor: '#1a1a22', borderWidth: 1, borderColor: 'rgba(255,211,105,0.18)', borderRadius: 14, padding: 14, shadowColor: '#ffd369', shadowOpacity: 0.08, shadowRadius: 12, shadowOffset: { width: 0, height: 0 }, }, titleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, }, title: { fontFamily: 'Menlo', fontSize: 16, fontWeight: '800', color: '#ffd369', letterSpacing: 2, }, livePill: { flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(74,158,93,0.15)', borderWidth: 1, borderColor: 'rgba(74,158,93,0.5)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, }, liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9e5d', }, liveDotPaused: { backgroundColor: '#c44e5a', }, liveText: { fontFamily: 'Menlo', fontSize: 9, fontWeight: '700', color: '#6dd184', letterSpacing: 1, }, metricsGrid: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#0f0f14', borderRadius: 10, paddingVertical: 10, }, metricCell: { flex: 1, alignItems: 'center', }, metricDivider: { width: 1, height: 32, backgroundColor: 'rgba(255,255,255,0.08)', }, metricLabel: { fontFamily: 'Menlo', fontSize: 9, fontWeight: '700', color: 'rgba(255,255,255,0.5)', letterSpacing: 1, marginBottom: 4, }, metricValue: { fontFamily: 'Menlo', fontSize: 17, fontWeight: '800', color: '#ffd369', letterSpacing: -0.3, }, instructions: { fontFamily: 'Menlo', fontSize: 10, color: 'rgba(255,255,255,0.45)', marginTop: 10, textAlign: 'center', }, // Arena arena: { backgroundColor: '#0f0f14', borderWidth: 1, borderColor: 'rgba(255,211,105,0.15)', borderRadius: 14, overflow: 'hidden', marginTop: 12, alignSelf: 'center', }, proseLine: { position: 'absolute', height: PROSE_LH, fontFamily: 'Menlo', fontSize: 10, lineHeight: PROSE_LH, color: 'rgba(150, 210, 165, 0.7)', overflow: 'hidden', }, brickText: { fontFamily: 'Menlo', fontSize: 12, fontWeight: '800', color: '#0a0a0c', letterSpacing: 0.5, }, powerBadge: { position: 'absolute', right: 12, top: 10, backgroundColor: '#ffd369', paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, shadowColor: '#ffd369', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 }, }, powerText: { fontFamily: 'Menlo', fontSize: 10, fontWeight: '800', color: '#0a0a0c', letterSpacing: 0.5, }, // Game over overlay overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(10,10,12,0.85)', justifyContent: 'center', alignItems: 'center', gap: 12, }, overlayTitle: { fontFamily: 'Menlo', fontSize: 22, fontWeight: '800', color: '#ffd369', letterSpacing: 2, }, overlayScore: { fontFamily: 'Menlo', fontSize: 12, color: 'rgba(255,255,255,0.6)', letterSpacing: 1, }, overlayBtn: { backgroundColor: '#ffd369', paddingHorizontal: 20, paddingVertical: 10, borderRadius: 999, marginTop: 8, shadowColor: '#ffd369', shadowOpacity: 0.6, shadowRadius: 10, shadowOffset: { width: 0, height: 0 }, }, overlayBtnText: { fontFamily: 'Menlo', fontSize: 12, fontWeight: '800', color: '#0a0a0c', letterSpacing: 1, }, // Bottom action bar actionBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, marginTop: 8, }, footerText: { fontFamily: 'Menlo', fontSize: 9, color: 'rgba(255,255,255,0.3)', flex: 1, letterSpacing: 0.3, }, resetBtn: { backgroundColor: 'transparent', borderWidth: 1, borderColor: 'rgba(255,211,105,0.5)', paddingHorizontal: 14, paddingVertical: 7, borderRadius: 999, }, resetBtnText: { fontFamily: 'Menlo', fontSize: 10, fontWeight: '800', color: '#ffd369', letterSpacing: 1, }, })