view react_games/src/Wordle/main.tsx @ 71:75de5903355c

Giagantic changes that update Dowa library to be more align with stb style array and hashmap. Updated Seobeo to be caching on server side instead of file level caching. Deleted bunch of things I don't really use.
author June Park <parkjune1995@gmail.com>
date Sun, 28 Dec 2025 20:34:22 -0800
parents fb9bcd3145cb
children
line wrap: on
line source

import { CSSProperties, useCallback, useEffect, useReducer } from 'react';

/**
 * Question:

 * Create a playable Wordle game in the browser using React within one hour. The game should allow the player to guess a hidden 5-letter word in at most 6 tries. After each guess, display feedback for each letter: correct letter and position (green), correct letter wrong position (yellow), or not in the word (gray). The game ends when the player guesses the word or runs out of tries.
 * 
 * Requirements:
 * 
 * Choose a fixed 5-letter target word in your code.
 * 
 * Render a grid showing guesses made and remaining attempts.
 * 
 * Accept input from the user (keyboard or on-screen).
 * 
 * Color letters according to correctness after each guess.
 * 
 * Display a win or lose message at the end of the game.
 */

const MAX_WORD_LEN = 5;
const MAX_ATTEMP_LEN = 6;

interface Position {
  letterPos: number;
  attempPos: number;
}

enum WordStatus {
  CORRECT_IN_POSITION,
  CORRECT_IN_WRONG_POSITION,
  DEFAULT,
}

interface Letter {
  value: string;
  status: WordStatus;
}

type Word = Letter[]
type Board = Word[];

enum GameStatus {
  PLAYABLE="Try to guess the word!",
  WON="You won!",
  LOST="You Lost!",
}

interface GameState {
  board: Board;
  targetWord: string[];
  position: Position;
  status: GameStatus;
}

const constructGameState = (): GameState => {
  const board: Board = Array.from(
    { length: MAX_ATTEMP_LEN }, () => {
      return Array.from({ length: MAX_WORD_LEN }, () => {
        return {
          value: "",
          status: WordStatus.DEFAULT,
        }
      })
    }
  );
  // dummy will get replace when it hits db
  const targetWord: string[] = "hello".split('');
  const position: Position = {
    letterPos: 0,
    attempPos: 0,
  }
  return {
    board,
    status: GameStatus.PLAYABLE,
    targetWord,
    position,
  }
}

interface LetterComponentProp {
  letter: Letter;
}

interface StyleProp {
  board: CSSProperties;
  letter: (wordStatus: WordStatus) =>  CSSProperties;
}

const styles: StyleProp = {
  board: {
    display: "grid",
    gridTemplateColumns: `repeat(${MAX_WORD_LEN}, 1fr)`,
    width: 400,
    gap: 8,
  },
  letter: (wordStatus: WordStatus): CSSProperties => {
    let backgroundColor: string = "#e6e6e6";
    switch(wordStatus) {
      case WordStatus.CORRECT_IN_POSITION:
        backgroundColor = "green";
        break;
      case WordStatus.CORRECT_IN_WRONG_POSITION:
        backgroundColor = "yellow";
        break;
      case WordStatus.DEFAULT:
        break;
    }
    return {
      display: "flex",
      justifyContent: "center",
      alignItems: "center",
      backgroundColor,
      width: "100%",
      aspectRatio: "1 / 1",
      border: "1px solid black",
    }
  }
}

const LetterComponent = ({letter}: LetterComponentProp) => {
  return (
    <div style={styles.letter(letter.status)}> 
     {letter.value}
    </div>
  )
}

enum GameActionEnum {
  INITIALZE,
  PLACE,
};

type GameAction =
  | { type: GameActionEnum.PLACE, character: string }
  | { type: GameActionEnum.INITIALZE, targetWord: string };

const gameStateReducer = (state: GameState, action: GameAction): GameState => {
  if (state.status != GameStatus.PLAYABLE) return state;

  switch(action.type) {
    case GameActionEnum.INITIALZE:
      return {
        ...state,
        targetWord: action.targetWord.split(''),
      };
    case GameActionEnum.PLACE:
      const { character } = action;
      let newBoard = state.board.map(
        (word, attempIdx) => attempIdx === state.position.attempPos ?
          word.map(
            (letter, letterIdx) => letterIdx === state.position.letterPos ?
              {...letter, value: character} : letter) : word);
      const newPosition = calculatePosition(state.position);
      let newStatus: GameStatus = state.status;
      if (newPosition.letterPos === 0) {
        const {correctLetterIdxes, correctWrongPositionLetterIndex} = gameLogic(newBoard[state.position.attempPos], state.targetWord);
        newBoard = newBoard.map(
          (word, attempIdx) => attempIdx === state.position.attempPos ?
            word.map(
              (letter, letterIdx) => correctLetterIdxes.some((value) => value === letterIdx) ?
                {...letter, status: WordStatus.CORRECT_IN_POSITION } : 
                  (correctWrongPositionLetterIndex.some((value) => value === letterIdx) ? 
                    {...letter, status: WordStatus.CORRECT_IN_WRONG_POSITION} : letter)
            )
         : word);
         newStatus = correctLetterIdxes.length === MAX_WORD_LEN ? 
           GameStatus.WON : (
             state.position.attempPos + 1 === MAX_ATTEMP_LEN ? 
               GameStatus.LOST : GameStatus.PLAYABLE
         );
      }
      return {
        ...state,
        board: newBoard,
        position: newPosition,
        status: newStatus,
      };
  }
}

function gameLogic(word: Word, targetWord: string[]) {
  const map: Record<string, number> = {};
  const correctLetterIdxes: number[] = []
  const correctWrongPositionLetterIndex: number[] = []
  for (let i = 0; i < MAX_WORD_LEN; i++) {
    map[targetWord[i]] ? map[targetWord[i]]++ : map[targetWord[i]] = 1;
  }
  for (let i = 0; i < MAX_WORD_LEN; i++) {
    if (word[i].value === targetWord[i]) {
      correctLetterIdxes.push(i);
      map[targetWord[i]]--;
    }
  }
  for (let i = 0; i < MAX_WORD_LEN; i++) {
    if (correctLetterIdxes.some((value) => value===i)) continue;
    if (
      targetWord.some((value) => value===word[i].value && map[value] > 0)
    ) {
      correctWrongPositionLetterIndex.push(i);
      map[word[i].value]--;
    }
  }
  return {
    correctLetterIdxes,
    correctWrongPositionLetterIndex,
  }
}

function calculatePosition(position: Position): Position {
  const letterPos = (position.letterPos + 1) % MAX_WORD_LEN;
  const attempPos = (
    (position.letterPos + 1) === MAX_WORD_LEN ? 
      position.attempPos + 1 : position.attempPos) % MAX_ATTEMP_LEN;
  return {
    letterPos,
    attempPos,
  }
}

const Wordle = () => {
  const [gameState, gameDispatch] = useReducer(gameStateReducer, null, constructGameState);

  useEffect(() => {
    (async() => {
      const res = await fetch('/api/v1/wordle');
      if (!res.ok) return;
      const response = await res.json();
      gameDispatch({ type: GameActionEnum.INITIALZE, targetWord: response.targetWord });
    })();
  }, []);

  const placeLetters = useCallback((event: KeyboardEvent) => {
    const letters = event.key.trim().toLowerCase();
    const regex = new RegExp('[a-z]');
    if (
      !regex.exec(letters) ||
      letters === "enter"
    ) {
      return;
    }
    gameDispatch({ type: GameActionEnum.PLACE, character: event.key.trim() })
  }, [gameDispatch]);

  useEffect(() => {
    document.addEventListener("keypress", placeLetters)
  }, [])

  return (
    <>
      <h1> Wordle </h1>
      <h2> {gameState.status} </h2>
      <div style={styles.board}>
        {gameState.board.map(
          (word, wordIdx) => word.map(
            (letter, letterIdx) => (
              <LetterComponent key={`${wordIdx}-${letterIdx}`} letter={letter} />
            ))
        )} 
      </div>
    </>
  );
}

export {
  Wordle,
}