├── assets ├── icon.png ├── favicon.png ├── splash.png └── adaptive-icon.png ├── app ├── constants │ ├── gameConstants.ts │ └── fiveLetterWords.json ├── components │ ├── ScreenHeader.tsx │ ├── SourceLink.tsx │ ├── Button.tsx │ ├── TextBlock.tsx │ └── Keyboard.tsx ├── gameUtils.ts └── GameScreen.tsx ├── tsconfig.json ├── babel.config.js ├── .prettierrc.js ├── .expo-shared └── assets.json ├── .gitignore ├── .eslintrc.js ├── App.tsx ├── readme.md ├── app.json ├── package.json └── web └── index.html /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LonelyCpp/react-native-wordle/HEAD/assets/icon.png -------------------------------------------------------------------------------- /app/constants/gameConstants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_GUESSES = 6; 2 | export const MAX_WORD_LEN = 5; 3 | -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LonelyCpp/react-native-wordle/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LonelyCpp/react-native-wordle/HEAD/assets/splash.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LonelyCpp/react-native-wordle/HEAD/assets/adaptive-icon.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: false, 3 | bracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | arrowParens: 'avoid', 7 | }; 8 | -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native-community', 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | overrides: [ 7 | { 8 | files: ['*.ts', '*.tsx'], 9 | rules: { 10 | '@typescript-eslint/no-shadow': ['error'], 11 | 'no-shadow': 'off', 12 | 'no-undef': 'off', 13 | }, 14 | }, 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {SafeAreaView, StyleSheet, View} from 'react-native'; 3 | import SourceLink from './app/components/SourceLink'; 4 | import ScreenHeader from './app/components/ScreenHeader'; 5 | import GameScreen from './app/GameScreen'; 6 | 7 | export default function App() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | const styles = StyleSheet.create({ 20 | container: { 21 | flex: 1, 22 | backgroundColor: '#000', 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## react native wordle 2 | 3 | A simple re-implementation of wordle. 4 | 5 | - guess the randomly choosen 5 letter word 6 | - you get 6 tries 7 | - correct letters are show green, incorrect letters are show yellow 8 | - letters that don't exist in the word, are shown in grey 9 | - a shareable emoji representation of your score 10 | 11 | Play it on the web here : https://lonelycpp.github.io/react-native-wordle/ 12 | 13 | Tech stack 14 | 15 | - react native 16 | - expo (+ react native web) 17 | - typescript 18 | 19 | ## acknowledgements 20 | 21 | - https://www.powerlanguage.co.uk/wordle/ (original game) 22 | - https://github.com/dwyl/english-words (word list) 23 | - https://github.com/hannahcode/wordle/blob/main/src/constants/wordlist.ts (better word list) 24 | -------------------------------------------------------------------------------- /app/components/ScreenHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {StyleSheet, Text, View} from 'react-native'; 3 | 4 | const ScreenHeader = () => { 5 | return ( 6 | 7 | Wordle 8 | react native 9 | 10 | ); 11 | }; 12 | 13 | const styles = StyleSheet.create({ 14 | container: { 15 | paddingVertical: 12, 16 | alignItems: 'center', 17 | justifyContent: 'center', 18 | }, 19 | title: { 20 | color: '#df928e', 21 | fontWeight: '200', 22 | fontSize: 32, 23 | }, 24 | subtitle: { 25 | color: '#91e5f6', 26 | fontWeight: '400', 27 | fontSize: 12, 28 | }, 29 | }); 30 | 31 | export default ScreenHeader; 32 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "React Native Wordle", 4 | "description": "A clone of the Wordle game, built on React Native for web and mobile", 5 | "slug": "react-native-wordle", 6 | "version": "1.0.0", 7 | "orientation": "portrait", 8 | "icon": "./assets/icon.png", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "updates": { 15 | "fallbackToCacheTimeout": 0 16 | }, 17 | "assetBundlePatterns": ["**/*"], 18 | "ios": { 19 | "supportsTablet": true 20 | }, 21 | "android": { 22 | "adaptiveIcon": { 23 | "foregroundImage": "./assets/adaptive-icon.png", 24 | "backgroundColor": "#FFFFFF" 25 | } 26 | }, 27 | "web": { 28 | "favicon": "./assets/favicon.png" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/components/SourceLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Linking, Platform, StyleSheet, Text, View} from 'react-native'; 3 | 4 | const HOME_URL = 'https://github.com/LonelyCpp/react-native-wordle'; 5 | 6 | const SourceLink = () => { 7 | return ( 8 | 9 | Linking.openURL(HOME_URL) : undefined 16 | }> 17 | (github) 18 | 19 | 20 | ); 21 | }; 22 | 23 | const styles = StyleSheet.create({ 24 | container: { 25 | position: 'absolute', 26 | right: 12, 27 | top: 16, 28 | }, 29 | subtitle: { 30 | color: '#5998c5', 31 | fontWeight: '400', 32 | fontSize: 16, 33 | }, 34 | }); 35 | 36 | export default SourceLink; 37 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Pressable, StyleSheet, Text, View} from 'react-native'; 3 | 4 | interface ButtonProps { 5 | cta: string; 6 | onPress(): void; 7 | } 8 | 9 | const Button = (props: ButtonProps) => { 10 | const {cta, onPress} = props; 11 | 12 | const [isPressedIn, setIsPressedIn] = useState(false); 13 | 14 | return ( 15 | setIsPressedIn(true)} 18 | onPressOut={() => setIsPressedIn(false)}> 19 | 20 | {cta} 21 | 22 | 23 | ); 24 | }; 25 | 26 | const styles = StyleSheet.create({ 27 | button: { 28 | paddingHorizontal: 16, 29 | paddingVertical: 12, 30 | borderWidth: 1, 31 | borderRadius: 4, 32 | borderColor: '#fff', 33 | }, 34 | cta: { 35 | color: '#fff', 36 | fontSize: 16, 37 | fontWeight: 'bold', 38 | }, 39 | active: { 40 | elevation: 2, 41 | }, 42 | }); 43 | 44 | export default Button; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-wordle", 3 | "version": "1.0.0", 4 | "homepage": "https://lonelycpp.github.io/react-native-wordle", 5 | "main": "node_modules/expo/AppEntry.js", 6 | "scripts": { 7 | "start": "expo start", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "eject": "expo eject", 12 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 13 | "deploy": "gh-pages -d web-build", 14 | "predeploy": "expo build:web" 15 | }, 16 | "dependencies": { 17 | "expo": "~44.0.0", 18 | "expo-status-bar": "~1.2.0", 19 | "react": "17.0.1", 20 | "react-dom": "17.0.1", 21 | "react-native": "0.64.3", 22 | "react-native-web": "0.17.1" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.12.9", 26 | "@react-native-community/eslint-config": "^2.0.0", 27 | "@types/react": "~17.0.21", 28 | "@types/react-native": "~0.64.12", 29 | "@typescript-eslint/eslint-plugin": "^5.7.0", 30 | "@typescript-eslint/parser": "^5.7.0", 31 | "eslint": "^7.14.0", 32 | "gh-pages": "^3.2.3", 33 | "typescript": "~4.3.5" 34 | }, 35 | "private": true 36 | } 37 | -------------------------------------------------------------------------------- /app/gameUtils.ts: -------------------------------------------------------------------------------- 1 | import fiveLetterWords from './constants/fiveLetterWords.json'; 2 | import {MAX_GUESSES} from './constants/gameConstants'; 3 | 4 | export const getInitialBoard = (): string[][] => { 5 | const board: string[][] = []; 6 | for (let i = 0; i < 6; i++) { 7 | board.push(new Array(5).fill('')); 8 | } 9 | 10 | return board; 11 | }; 12 | 13 | export const getRandomWord = (): string => { 14 | const len = fiveLetterWords.length; 15 | const randomIndex = Math.floor(Math.random() * 100000) % len; 16 | return fiveLetterWords[randomIndex].toUpperCase(); 17 | }; 18 | 19 | export const getWordleEmoji = (word: string, guessList: string[]): string => { 20 | const hasWon = guessList[guessList.length - 1] === word; 21 | 22 | let output = `Wordle ${hasWon ? guessList.length : 'x'}/${MAX_GUESSES}\n\n`; 23 | 24 | guessList.forEach(row => { 25 | let line = ''; 26 | 27 | row.split('').forEach((char, colIndex) => { 28 | if (char === word[colIndex]) { 29 | line += '🟩'; 30 | } else if (word.includes(char)) { 31 | line += '🟨'; 32 | } else { 33 | line += '⬜️'; 34 | } 35 | }); 36 | 37 | output += line + '\n'; 38 | }); 39 | 40 | return output; 41 | }; 42 | -------------------------------------------------------------------------------- /app/components/TextBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {StyleSheet, Text, View} from 'react-native'; 3 | 4 | export enum TextBlockState { 5 | GUESS = 'guess', 6 | CORRECT = 'correct', 7 | POSSIBLE = 'possible', 8 | INCORRECT = 'incorrect', 9 | } 10 | 11 | const ColorMap: Record = { 12 | [TextBlockState.GUESS]: 'transparent', 13 | [TextBlockState.CORRECT]: '#76b041', 14 | [TextBlockState.POSSIBLE]: '#FFC914', 15 | [TextBlockState.INCORRECT]: '#8b939c', 16 | }; 17 | 18 | interface TextBlockProps { 19 | text: string; 20 | state: TextBlockState; 21 | } 22 | 23 | const TextBlock = (props: TextBlockProps) => { 24 | const {text, state} = props; 25 | 26 | return ( 27 | 34 | {text.toUpperCase()} 35 | 36 | ); 37 | }; 38 | 39 | const styles = StyleSheet.create({ 40 | container: { 41 | width: 40, 42 | height: 40, 43 | borderWidth: 1, 44 | borderRadius: 4, 45 | borderColor: 'white', 46 | alignItems: 'center', 47 | justifyContent: 'center', 48 | }, 49 | text: { 50 | fontSize: 16, 51 | color: '#fff', 52 | fontWeight: 'bold', 53 | }, 54 | }); 55 | 56 | export default TextBlock; 57 | -------------------------------------------------------------------------------- /app/components/Keyboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Pressable, StyleSheet, Text, View} from 'react-native'; 3 | 4 | interface KeyboardProps { 5 | onKeyPress(char: string): void; 6 | disabledKeyList: string[]; 7 | } 8 | 9 | export enum SpecialKeyboardKeys { 10 | DELETE = 'delete', 11 | GUESS = 'guess', 12 | } 13 | 14 | const keySequence: string[][] = [ 15 | ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], 16 | ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], 17 | ['Z', 'X', 'C', 'V', 'B', 'N', 'M'], 18 | [SpecialKeyboardKeys.DELETE, SpecialKeyboardKeys.GUESS], 19 | ]; 20 | 21 | const Keyboard = (props: KeyboardProps) => { 22 | const {onKeyPress, disabledKeyList} = props; 23 | 24 | return ( 25 | <> 26 | {keySequence.map((row, rowIndex) => { 27 | return ( 28 | 29 | {row.map(key => { 30 | const isDisabled = disabledKeyList.includes(key); 31 | return ( 32 | onKeyPress(key)}> 36 | 38 | 40 | {key} 41 | 42 | 43 | 44 | ); 45 | })} 46 | 47 | ); 48 | })} 49 | 50 | ); 51 | }; 52 | 53 | const styles = StyleSheet.create({ 54 | row: { 55 | flexDirection: 'row', 56 | marginBottom: 5, 57 | }, 58 | cell: { 59 | padding: 5, 60 | paddingHorizontal: 8, 61 | margin: 4, 62 | borderRadius: 2, 63 | borderWidth: 1, 64 | borderColor: 'white', 65 | }, 66 | cellDisabled: { 67 | borderColor: 'grey', 68 | }, 69 | text: { 70 | color: 'white', 71 | fontSize: 16, 72 | }, 73 | textDisabled: { 74 | color: 'grey', 75 | }, 76 | }); 77 | 78 | export default Keyboard; 79 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 20 | 21 | 25 | 28 | 29 | 30 | 31 | 34 | 37 | 40 | 41 | %WEB_TITLE% 42 | 90 | 91 | 92 | 93 | 97 | 138 | 139 |
140 | 141 | 142 | -------------------------------------------------------------------------------- /app/GameScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; 2 | import {StyleSheet, Text, View, Clipboard, Platform} from 'react-native'; 3 | import Button from './components/Button'; 4 | import Keyboard, {SpecialKeyboardKeys} from './components/Keyboard'; 5 | import TextBlock, {TextBlockState} from './components/TextBlock'; 6 | import {MAX_GUESSES, MAX_WORD_LEN} from './constants/gameConstants'; 7 | import {getInitialBoard, getRandomWord, getWordleEmoji} from './gameUtils'; 8 | 9 | const BOARD_TEMPLATE = getInitialBoard(); 10 | 11 | const GameScreen = () => { 12 | const [guessList, setGuessList] = useState([]); 13 | const [inputWord, setInputWord] = useState(''); 14 | const [gameOver, setGameOver] = useState(false); 15 | const [disabledLetters, setDisabledLetters] = useState([]); 16 | 17 | const wordToGuess = useRef('xxxxx'); 18 | 19 | useEffect(() => { 20 | if (gameOver === false) { 21 | wordToGuess.current = getRandomWord(); 22 | setInputWord(''); 23 | setGuessList([]); 24 | } 25 | }, [gameOver]); 26 | 27 | useEffect(() => { 28 | const guessLen = guessList.length; 29 | if (guessList[guessLen - 1] === wordToGuess.current) { 30 | setGameOver(true); 31 | } else if (guessLen === MAX_GUESSES) { 32 | setGameOver(true); 33 | } 34 | }, [guessList]); 35 | 36 | useEffect(() => { 37 | const list: string[] = []; 38 | 39 | guessList.forEach(word => { 40 | word.split('').forEach(letter => { 41 | console.log({letter}); 42 | if (!wordToGuess.current.includes(letter)) { 43 | list.push(letter); 44 | } 45 | }); 46 | }); 47 | 48 | setDisabledLetters(list); 49 | }, [guessList]); 50 | 51 | const onKeyPress = useCallback( 52 | (key: string) => { 53 | if (key === SpecialKeyboardKeys.DELETE) { 54 | setInputWord(prev => prev.slice(0, -1)); 55 | } else if (key === SpecialKeyboardKeys.GUESS) { 56 | setGuessList(prev => [...prev, inputWord.toUpperCase()]); 57 | setInputWord(''); 58 | } else if (key.length === 1) { 59 | setInputWord(prev => { 60 | if (prev.length < MAX_WORD_LEN && !disabledLetters.includes(key)) { 61 | return prev + key; 62 | } 63 | 64 | return prev; 65 | }); 66 | } 67 | }, 68 | [disabledLetters, inputWord], 69 | ); 70 | 71 | useEffect(() => { 72 | if (Platform.OS === 'web') { 73 | const callback = (event: KeyboardEvent) => { 74 | const key = event.key; 75 | 76 | if (/^[A-Za-z]$/.test(key)) { 77 | onKeyPress(key.toUpperCase()); 78 | } else if (key === 'Enter' && inputWord.length === MAX_WORD_LEN) { 79 | onKeyPress(SpecialKeyboardKeys.GUESS); 80 | } else if (key === 'Backspace') { 81 | onKeyPress(SpecialKeyboardKeys.DELETE); 82 | } 83 | }; 84 | 85 | window.addEventListener('keyup', callback); 86 | return () => window.removeEventListener('keyup', callback); 87 | } 88 | }, [inputWord.length, onKeyPress]); 89 | 90 | const wordleEmoji: string = useMemo(() => { 91 | if (!gameOver) { 92 | return ''; 93 | } 94 | 95 | return getWordleEmoji(wordToGuess.current, guessList); 96 | }, [gameOver, guessList]); 97 | 98 | return ( 99 | 100 | {BOARD_TEMPLATE.map((row, rowIndex) => { 101 | return ( 102 | 103 | {row.map((_, colIndex) => { 104 | const guessLetter = guessList[rowIndex]?.[colIndex]; 105 | let state: TextBlockState = TextBlockState.GUESS; 106 | 107 | if (guessLetter === undefined) { 108 | state = TextBlockState.GUESS; 109 | } else if (guessLetter === wordToGuess.current[colIndex]) { 110 | state = TextBlockState.CORRECT; 111 | } else if (wordToGuess.current.includes(guessLetter)) { 112 | state = TextBlockState.POSSIBLE; 113 | } else { 114 | state = TextBlockState.INCORRECT; 115 | } 116 | 117 | const letterToShow = 118 | rowIndex === guessList.length 119 | ? inputWord[colIndex] 120 | : guessLetter; 121 | 122 | return ( 123 | 124 | 125 | 126 | ); 127 | })} 128 | 129 | ); 130 | })} 131 | 132 | 133 | {gameOver ? ( 134 | <> 135 | Game Over! 136 | 137 | The word was : {wordToGuess.current} 138 | 139 | 140 | 141 | {wordleEmoji} 142 | 143 | 144 | 145 |