Skip to content

Instantly share code, notes, and snippets.

@huytd
Last active March 17, 2024 20:51
Show Gist options
  • Save huytd/6a1a6a7b34a0d0abcac00b47e3d01513 to your computer and use it in GitHub Desktop.
Save huytd/6a1a6a7b34a0d0abcac00b47e3d01513 to your computer and use it in GitHub Desktop.
Wordle in less than 50 lines of Bash

image

How to use:

./wordle.sh

Or try the unlimit mode:

./wordle.sh unlimit
words=($(grep '^\w\w\w\w\w$' /usr/share/dict/words | tr '[a-z]' '[A-Z]'))
actual=${words[$[$RANDOM % ${#words[@]}]]} end=false guess_count=0 max_guess=6
if [[ $1 == "unlimit" ]]; then
max_guess=999999
fi
while [[ $end != true ]]; do
guess_count=$(( $guess_count + 1 ))
if [[ $guess_count -le $max_guess ]]; then
echo "Enter your guess ($guess_count / $max_guess):"
read guess
guess=$(echo $guess | tr '[a-z]' '[A-Z]')
if [[ " ${words[*]} " =~ " $guess " ]]; then
output="" remaining=""
if [[ $actual == $guess ]]; then
echo "You guessed right!"
for ((i = 0; i < ${#actual}; i++)); do
output+="\033[30;102m ${guess:$i:1} \033[0m"
done
printf "$output\n"
end=true
else
for ((i = 0; i < ${#actual}; i++)); do
if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
remaining+=${actual:$i:1}
fi
done
for ((i = 0; i < ${#actual}; i++)); do
if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
if [[ "$remaining" == *"${guess:$i:1}"* ]]; then
output+="\033[30;103m ${guess:$i:1} \033[0m"
remaining=${remaining/"${guess:$i:1}"/}
else
output+="\033[30;107m ${guess:$i:1} \033[0m"
fi
else
output+="\033[30;102m ${guess:$i:1} \033[0m"
fi
done
printf "$output\n"
fi
else
echo "Please enter a valid word with 5 letters!";
guess_count=$(( $guess_count - 1 ))
fi
else
echo "You lose! The word is:"
echo $actual
end=true
fi
done
@MikeLippold
Copy link

MikeLippold commented Feb 3, 2022

Nicely done.

The New General Service List is a list of commonly used words for English language learners. It has a list of words ranked by frequency. I don't remember exactly how they determine frequency of use, but it has proven to work well for me in building some word games.

http://www.newgeneralservicelist.org

It's licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.

@rawiriblundell
Copy link

rawiriblundell commented Feb 4, 2022

Ok, so we throw out the ~50 line thing and put a bit more into it...

Demo:

asciicast

Code:

#!/bin/bash
# A bash implementation of wordle
# Provenance: https://gist.github.com/huytd/6a1a6a7b34a0d0abcac00b47e3d01513

# Figure out our dictionary and vars
wordle_dir="${XDG_DATA_HOME:-$HOME/.local/share}/wordle"
dict="${wordle_dir}/wordles"
mkdir -p "${wordle_dir}" || exit 1
dict_url="https://gist.githubusercontent.com/prichey/95db6bdef37482ba3f6eb7b4dec99101/raw/"
if [[ ! -f "${wordle_dir}/wordles" ]]; then
    if command -v curl >/dev/null 2>&1; then
        curl -s "${dict_url}" | tr '[:lower:]' '[:upper:]' > "${dict}"
    else
        LC_COLLATE=C grep -Eh '^[A-Za-z].{4}$' /usr/{,share/}dict/words 2>/dev/null | 
            grep -v "'" |
            tr '[:lower:]' '[:upper:]' > "${dict}"
    fi
fi

# If 'shuf' is present, we use it.  It's simply the best thing for this.
if command -v shuf >/dev/null 2>&1; then
    actual=$(shuf -n 1 "${dict}")
# Otherwise, if we're going to modulo, let's at least try to debias it
else
    wordcount=$(wc -l < "${dict}")
    randmax=$(( 32768 / wordcount * wordcount ))
    while (( (rand=RANDOM) >= randmax )); do :; done
    rand=$(( rand % wordcount ))
    actual=$(sed -ne "${rand}{p;q}" "${dict}")
fi

# Usage: printcharbox int color char color char color char color char color char
# Example: printcharbox 1 green g green h white o white s yellow t
# int tells the function which iteration we're on.
# If it's 1, the function prints the top bar, otherwise it doesn't
# Either way, '\033[1F\033[K' is the magic:  Move up one line, empty the line.
printcharbox() {
    while (( "${#}" > 0 )); do
      case "${1}" in
        (1)      printf -- '\033[1F\033[K%s\n' "+---+---+---+---+---+"; shift 1 ;;
        ([2-9])  printf -- '\033[1F\033[K%s' ""; shift 1 ;;
        (green)  printf -- '|\033[30;102m %s \033[0m' "${2}"; shift 2 ;;
        (yellow) printf -- '|\033[30;103m %s \033[0m' "${2}"; shift 2 ;;
        (white)  printf -- '|\033[30;107m %s \033[0m' "${2}"; shift 2 ;;
        (red)    printf -- '|\033[30;101m %s \033[0m' "${2}"; shift 2 ;;
        (*)      printf -- '%s\n' "Unrecognised input" >&2; exit 1 ;;
      esac
      # For versions of sleep that don't support floats, we quietly failover
      sleep 0.5 >/dev/null 2>&1 || sleep 1
    done
    printf -- '%s\n' "|" "+---+---+---+---+---+"
}

[[ "${1}" == "unlimit" ]] && max_guess=999999
for (( guess_count=1; guess_count<=${max_guess:-6}; guess_count++ )); do
    fail_count=0
    until grep -xiq "^${guess}$" "${dict}"; do
        read -rp "Please enter a valid 5 letter word (${guess_count} / ${max_guess:-6}): " guess
        (( fail_count++ ))
    done
    # If the above interaction has iterated fail_count, we use it to determine
    # how many lines to walk up and blank out
    for (( i=1; i<fail_count; i++ )); do
        printf -- '\033[1F\033[K%s' ""
    done 
    guess="${guess^^}"; output=( "${guess_count}" ); remaining=""
    if [[ "${actual}" == "${guess}" ]]; then
        for ((i=0; i<5; i++)); do
            output+=( green "${guess:$i:1}" )
        done
        printcharbox "${output[@]}"; exit 0
    fi
    for ((i=0; i<5; i++)); do
        [[ "${actual:$i:1}" != "${guess:$i:1}" ]] && remaining+=${actual:$i:1}
    done
    for ((i=0; i<5; i++)); do
        if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
            if [[ "${remaining}" == *"${guess:$i:1}"* ]]; then
                output+=( yellow "${guess:$i:1}" )
                remaining=${remaining/"${guess:$i:1}"/}
            else
                output+=( white "${guess:$i:1}" )
            fi
        else
            output+=( green "${guess:$i:1}" )
        fi
    done
    printcharbox "${output[@]}"; guess=""
done
# If we reach this point, then we're wrong.  Let's output the correct answer...
printf -- '%s\n' "" "Correct Answer:" ""
output=( 1 )
for ((i=0; i<5; i++)); do
    output+=( red "${actual:$i:1}" )
done
printcharbox "${output[@]}"
exit 1

@MikeLippold
Copy link

MikeLippold commented Feb 4, 2022

I've made a list of the 3000 most frequently used 5-letter words in the English language. It is based on the New General Service List.

https://raw.githubusercontent.com/MikeLippold/word-lists/main/ngsl-5-letter-words.txt

@mariecrane
Copy link

It looks like other people have made similar changes, but here's my version of this with Wordle's actual guess/answer lists and with an emoji output at the end like the real thing: https://gist.github.com/mariecrane/1c9ad6fe5220d452176c97f32fcf7155

@CqN
Copy link

CqN commented Feb 5, 2022

@elliottcarlson What is the difference between the wordle word list and the 'allowed list? I thought there were the same.

@CqN
Copy link

CqN commented Feb 5, 2022

@lenihan What is powerShell? What is the shebang for that?

@CqN
Copy link

CqN commented Feb 5, 2022

@tkaravou Assuming there is something magincal about 50 line limit, is there any restriction that this has to conform to some defined pretty print standard?

On trivial clauses like this:

if [[ $1 == "unlimit" ]]; then
    max_guess=999999
fi

to:

if [[ $1 == "unlimit" ]]; then max_guess=999999; fi

that bad? :)

@tkaravou
Copy link

tkaravou commented Feb 5, 2022

@CqN No the original post that linked to this repo was the author saying that he wrote it in under 50 lines.

@rawiriblundell
Copy link

@CqN: If you check my first post, you can see that that clause can be code golfed at least a step further:

[[ $1 == "unlimit" ]] && max_guess=999999

At one point I had the whole thing code golfed down to around 26 lines... but honestly, extreme code golfing is for people who like perl lol

@lenihan
Copy link

lenihan commented Feb 5, 2022

@CqN

What is powerShell?

Open source, cross platform "object oriented" shell. More here.

What is the shebang for that?

If you are running in bash: #!/usr/bin/env pwsh
if you are running in powershell, you don't need shebang for .ps1 files.

@tb3088
Copy link

tb3088 commented Feb 5, 2022

40 lines, and doesn't use any memory at all compared to implementations above. Also much easier to follow along IMO. Assumes suitable dictionary exists. So who's going to be the first to write it in 1 line of obfuscated Perl? This game is just a word version of Master Mind(tm)

#!/bin/bash -e
shopt -u nocaseglob nocasematch
: ${LETTERS:=5}; : ${ROUNDS:=6}; : ${DICTIONARY:=wordle.dict}

pool=abcdefghijklmnopqrstuvwxyz
hit='\e[30;102m'; match='\e[30;103m'; miss='\e[30;107m'; reset='\e[0m'
words=`wc -l < "$DICTIONARY"`
until (( ${line:-0} != 0 )); do line=$((RANDOM % $words)); done
declare -l guess= secret=$(sed -n "${line}p" "$DICTIONARY")

${DEBUG:+echo "$secret"}

for (( j=1; j <= $ROUNDS; j++ )); do
  read -ep "Enter your guess ($LETTERS letters | $j/$ROUNDS): " guess || exit
  (( ${#guess} == $LETTERS )) || { echo -e "  ERROR\timproper guess"; continue; }
  [[ "$guess" == "$secret" ]] && { win=1; break; }

  declare -a pad=() matched=()
  # mark hits while loading pad
  for (( i=0; i < $LETTERS; i++ )); do
    c=${guess:$i:1}; k=${secret:$i:1}
    [[ "$k" == "$c" ]] && pad[$i]='_' || pad[$i]=$k
  done

  for (( i=0; i < $LETTERS; i++ )); do
    c=${guess:$i:1}
    if [[ ${pad[$i]} == '_' ]]; then
      color=$hit; matched+=( $c )
    elif [[ `printf '%s' "${pad[@]}"` =~ "$c" ]]; then
      color=$match; pad=( ${pad[@]/$c/.} ); matched+=( $c )
    else
      color=$miss
      [[ `printf '%s' "${matched[@]}"` =~ "$c" ]] || pool=${pool/$c/ }
    fi
    echo -en "$color ${c^^} "
  done
  echo -e "${reset}\t\t${pool^^}\n"
done

[ ${win:-0} -eq 1 ] && echo "Congratulations!" ||
    echo "The word was '$secret'. Better luck next time!"

@pforret
Copy link

pforret commented Feb 5, 2022

Great stuff! I made a version with variable # of letters and multiple languages at https://github.com/pforret/shwordle

@CqN
Copy link

CqN commented Feb 5, 2022

@rawiriblundell
Thank you very much for pointing this out. I had seen your earlier post, and had made a mental note to study that later. I like newer practices and cleaner code. But I had not realized you had shrunk it down to 26 at that time.

BTW, if we do not care much about code readability, I think the code can be in one line by stringing all together, no? :) I do not think there is any construct that requires a line end. I am not shell expert. So the popular notion of lines of code is rather fluid.

@CqN
Copy link

CqN commented Feb 5, 2022

@tb3088
Thanks for posting. I do not think I had seen this particular formatting and syntax of bash before. I like this. Has this particular style got an accepted name such as Posix? You have used the no-op : extensively. I am intrigued and like to study further.

I would call it a 35 line version, really. There are 6 blank lines :)

PS. By experimenting I found out ${a:=3} is really defining a as a constant and setting it! Cannot be reassigned a new value before unsetting it. Great. I am eager to find out the name and the full set of syntax.

@tb3088
Copy link

tb3088 commented Feb 6, 2022

@CqN I'm not doing anything exotic. It's how I write all shell code. take a gander at https://github.com/tb3088/shell-environment/blob/master/.functions
Also read up on Bash Parameter Expansion. The original post was written rather poorly (disorganized) and tried to keep using strings when arrays make it so much more straightforward.

@devio-at
Copy link

devio-at commented Feb 6, 2022

I found that my dict/words contains accented words such as "éCLAT, éPéES, éTUDE". \w matches accented e, but [a-z] would not.

@CqN
Copy link

CqN commented Feb 6, 2022

@tb3088
Mathew, thanks for the feedback.

Now how would you perform your bash magic on this line
read -ep "Enter your guess ($LETTERS letters | $j/$ROUNDS): " guess || exit
so, I will see the guess I type in shown in capital?

@aaronNGi
Copy link

aaronNGi commented Feb 6, 2022

Here's a variant done in (Edit:) under 20 lines of bash: https://gist.github.com/aaronNGi/04a209b1cd17f1fa03af7a87eb14cc42

@BMDan
Copy link

BMDan commented Feb 9, 2022

Cleaned up a few things. Biggest changes:

  1. argv[1] can now be "unlimit" or "unlimited" (or anything else starting with "unlimit").
  2. Use proper termcap codes to set colors (should work better on a wider variety of terminals).
  3. Clean up input to use read(1) and to save ourselves an if indentation on the rest of the logic with a while.
  4. Provide more feedback on rejected words.
Full script
#!/bin/bash

function wordsgrep() {
  (IFS=$'\n'; echo "${words[*]}") | grep -qixF "${1:-inv.alid}"
}

words=($(grep -xE '\w{5}' /usr/share/dict/words | tr '[:lower:]' '[:upper:]'))
actual=${words[$[$RANDOM % ${#words[@]}]]} guess_count=0 max_guess=6
[[ "${1//unlimit}" != "${1:-}" ]] && max_guess=999999
while true; do
    guess_count=$(( $guess_count + 1 ))
    if [[ $guess_count -le $max_guess ]]; then
        while read -r -p "Enter your guess ($guess_count / $max_guess): " guess; do
            wordsgrep "$guess" && break
            [[ ${#guess} != 5 ]] && echo "Too short/long." && continue
            echo "Not a real word."
        done
        guess="$(tr '[:lower:]' '[:upper:]' <<<"$guess")"
        output="" remaining=""
        if [[ $actual == $guess ]]; then
            echo "You guessed right!"
            for ((i = 0; i < ${#actual}; i++)); do
                output+="$(tput setaf 0)$(tput setab 10) ${guess:$i:1} $(tput sgr0)"
            done
            echo "$output"
            break
        else
            for ((i = 0; i < ${#actual}; i++)); do
                if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
                    remaining+=${actual:$i:1}
                fi
            done
            for ((i = 0; i < ${#actual}; i++)); do
                if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
                    if [[ "$remaining" == *"${guess:$i:1}"* ]]; then
                        output+="$(tput setaf 0)$(tput setab 11) ${guess:$i:1} $(tput sgr0)"
                        remaining=${remaining/"${guess:$i:1}"/}
                    else
                        output+="$(tput setaf 0)$(tput setab 15) ${guess:$i:1} $(tput sgr0)"
                    fi
                else
                    output+="$(tput setaf 0)$(tput setab 10) ${guess:$i:1} $(tput sgr0)"
                fi
            done
            echo "$output"
        fi
    else
        echo "You lose! The word is:"
        echo $actual
        break
    fi
done
Diff (ignoring whitespace changes)
--- a/wordle.sh	2022-02-09 15:45:06.000000000 -0800
+++ b/wordle.sh	2022-02-09 15:45:06.000000000 -0800
@@ -1,23 +1,29 @@
-words=($(grep '^\w\w\w\w\w$' /usr/share/dict/words | tr '[a-z]' '[A-Z]'))
-actual=${words[$[$RANDOM % ${#words[@]}]]} end=false guess_count=0 max_guess=6
-if [[ $1 == "unlimit" ]]; then
-    max_guess=999999
-fi
-while [[ $end != true ]]; do
+#!/bin/bash
+
+function wordsgrep() {
+  (IFS=$'\n'; echo "${words[*]}") | grep -qixF "${1:-inv.alid}"
+}
+
+words=($(grep -xE '\w{5}' /usr/share/dict/words | tr '[:lower:]' '[:upper:]'))
+actual=${words[$[$RANDOM % ${#words[@]}]]} guess_count=0 max_guess=6
+[[ "${1//unlimit}" != "${1:-}" ]] && max_guess=999999
+while true; do
     guess_count=$(( $guess_count + 1 ))
     if [[ $guess_count -le $max_guess ]]; then
-        echo "Enter your guess ($guess_count / $max_guess):"
-        read guess
-        guess=$(echo $guess | tr '[a-z]' '[A-Z]')
-        if [[ " ${words[*]} " =~ " $guess " ]]; then
+        while read -r -p "Enter your guess ($guess_count / $max_guess): " guess; do
+            wordsgrep "$guess" && break
+            [[ ${#guess} != 5 ]] && echo "Too short/long." && continue
+            echo "Not a real word."
+        done
+        guess="$(tr '[:lower:]' '[:upper:]' <<<"$guess")"
             output="" remaining=""
             if [[ $actual == $guess ]]; then
                 echo "You guessed right!"
                 for ((i = 0; i < ${#actual}; i++)); do
-                    output+="\033[30;102m ${guess:$i:1} \033[0m"
+                output+="$(tput setaf 0)$(tput setab 10) ${guess:$i:1} $(tput sgr0)"
                 done
-                printf "$output\n"
-                end=true
+            echo "$output"
+            break
             else
                 for ((i = 0; i < ${#actual}; i++)); do
                     if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
@@ -27,24 +33,20 @@
                 for ((i = 0; i < ${#actual}; i++)); do
                     if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
                         if [[ "$remaining" == *"${guess:$i:1}"* ]]; then
-                            output+="\033[30;103m ${guess:$i:1} \033[0m"
+                        output+="$(tput setaf 0)$(tput setab 11) ${guess:$i:1} $(tput sgr0)"
                             remaining=${remaining/"${guess:$i:1}"/}
                         else
-                            output+="\033[30;107m ${guess:$i:1} \033[0m"
+                        output+="$(tput setaf 0)$(tput setab 15) ${guess:$i:1} $(tput sgr0)"
                         fi
                     else
-                        output+="\033[30;102m ${guess:$i:1} \033[0m"
+                    output+="$(tput setaf 0)$(tput setab 10) ${guess:$i:1} $(tput sgr0)"
                     fi
                 done
-                printf "$output\n"
-            fi
-        else
-            echo "Please enter a valid word with 5 letters!";
-            guess_count=$(( $guess_count - 1 ))
+            echo "$output"
         fi
     else
         echo "You lose! The word is:"
         echo $actual
-        end=true
+        break
     fi
 done

IMHO, storing the words as an array, while a neat trick, also gets pretty slow pretty quickly. A version without arrays to follow....

@BMDan
Copy link

BMDan commented Feb 9, 2022

As promised, the version without arrays. Also makes some minor stylistic changes, deletes an unnecessary loop, and adds an "abandon" feature (press CTRL-D at the prompt). 36 lines, total.

actual="$(sort -R /usr/share/dict/words | grep -xEm 1 '\w{5}' | tr '[:lower:]' '[:upper:]')"
guess_count=0 max_guess=6
[[ "${1//unlimit}" != "${1:-}" ]] && max_guess=999999
while true; do
    guess_count=$(( guess_count + 1 ))
    if [[ $guess_count -le $max_guess ]]; then
        while read -r -p "Enter your guess ($guess_count / $max_guess): " guess; do
            grep -ixF "${guess:-inv.alid}" /usr/share/dict/words | grep -xqE '\w{5}' && break
            [[ ${#guess} != 5 ]] && echo "Too short/long." && continue
            echo "Not a real word."
        done
        [ ${#guess} -eq 0 ] && echo && echo "Giving up so soon?  The answer was $actual." && break
        guess="$(tr '[:lower:]' '[:upper:]' <<<"$guess")"
        output="" remaining=""
        for ((i = 0; i < ${#actual}; i++)); do
            [[ "${actual:$i:1}" != "${guess:$i:1}" ]] && remaining+=${actual:$i:1}
        done
        for ((i = 0; i < ${#actual}; i++)); do
            if [[ "${actual:$i:1}" != "${guess:$i:1}" ]]; then
                if [[ "$remaining" == *"${guess:$i:1}"* ]]; then
                    output+="$(tput setaf 0)$(tput setab 11) ${guess:$i:1} $(tput sgr0)"
                    remaining=${remaining/"${guess:$i:1}"/}
                else
                    output+="$(tput setaf 0)$(tput setab 15) ${guess:$i:1} $(tput sgr0)"
                fi
            else
                output+="$(tput setaf 0)$(tput setab 10) ${guess:$i:1} $(tput sgr0)"
            fi
        done
        echo "$output"
        [ "$actual" = "$guess" ] && echo "You guessed right!" && break
    else
        echo "You lose!  The word was $(tput setaf 1)$(tput bold)$actual$(tput sgr0)."
        break
    fi
done

@kristinbell
Copy link

Here's a challenge for someone: Can we make a game that uses more than one language at a time? Would that just involve using two dictionaries? Or would the game need different rules?

@cjdinger
Copy link

I wrote a SAS version and placed it here sascommunities/wordle-sas. Uses the word lists from cfreshman (thanks) and arrays to check guesses.

example-game-dsobj

@aramvr
Copy link

aramvr commented Mar 18, 2022

I learned about this from an Oreilly blog post. Good job!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment