6
\$\begingroup\$

I wrote this two player tic-tac-toe program in bash. To make a move you enter a number between 0 and 8 which corresponds to the square:

0|1|2 3|4|5 6|7|8 

This is my code:

#!/usr/bin/env bash declare -r BOARD_SIZE=9 declare -r DEFAULT_SQUARE_VALUE=- declare -ra PLAYERS=(O X) draw_board() { for i in {1..3} do echo "${1:- }|${2:- }|${3:- }" shift 3 done } get_placement() { while : do echo "Please enter a valid move (0-$((BOARD_SIZE - 1)))" read n echo digit_pattern="^[0-$((BOARD_SIZE-1))]\$" args=("$@") if [[ $n =~ $digit_pattern ]] && [[ ${args[$n]} == $DEFAULT_SQUARE_VALUE ]] then placement=$n return fi done } # sets $ended to whether the game has ended and $winner to the winner get_winner() { ended=1 possible_draw=1 # check for draw for square in $@ do if [[ $square == $DEFAULT_SQUARE_VALUE ]] then possible_draw=0 break fi done # check for winners if [[ ${PLAYERS[*]} =~ "$1" ]] && [[ ${PLAYERS[*]} =~ "$5" ]] && [[ ${PLAYERS[*]} =~ "$9" ]] then if [[ "$1" == "$5" ]] && [[ "$5" == "$9" ]] then winner=$1 return fi fi if [[ ${PLAYERS[*]} =~ "$3" ]] && [[ ${PLAYERS[*]} =~ "$5" ]] && [[ ${PLAYERS[*]} =~ "$7" ]] then if [[ "$3" == "$5" ]] && [[ "$5" == "$7" ]] then winner=$3 return fi fi for i in {1..3} do if [[ ${PLAYERS[*]} =~ "$1" ]] && [[ ${PLAYERS[*]} =~ "$2" ]] && [[ ${PLAYERS[*]} =~ "$3" ]] then if [[ "$1" == "$2" ]] && [[ "$2" == "$3" ]] then winner=$1 return fi fi if [[ ${PLAYERS[*]} =~ "$1" ]] && [[ ${PLAYERS[*]} =~ "$4" ]] && [[ ${PLAYERS[*]} =~ "$7" ]] then if [[ "$1" == "$4" ]] && [[ "$4" == "$7" ]] then winner=$1 return fi fi shift 3 done winner='' if [[ $possible_draw == 1 ]] then return fi ended=0 } board=( $(for i in $(seq 1 $BOARD_SIZE); do echo "$DEFAULT_SQUARE_VALUE"; done) ) current_turn="O" while : do draw_board ${board[@]} get_placement ${board[@]} board[$placement]=$current_turn get_winner ${board[@]} if [[ "$ended" == 1 ]] then if [ -n "$winner" ] then echo "$winner has won!" echo draw_board ${board[@]} else echo "Draw!" echo draw_board ${board[@]} fi exit fi [[ $current_turn == ${PLAYERS[0]} ]] && current_turn=${PLAYERS[1]} || current_turn=${PLAYERS[0]} echo -en "\n\n\n" done 

Example game:

-|-|- -|-|- -|-|- Please enter a valid move (0-8) 0 O|-|- -|-|- -|-|- Please enter a valid move (0-8) 8 O|-|- -|-|- -|-|X Please enter a valid move (0-8) 6 O|-|- -|-|- O|-|X Please enter a valid move (0-8) 3 O|-|- X|-|- O|-|X Please enter a valid move (0-8) 2 O|-|O X|-|- O|-|X Please enter a valid move (0-8) 4 O|-|O X|X|- O|-|X Please enter a valid move (0-8) 1 O has won! O|O|O X|X|- O|-|X 

I do not know much about bash, so any feedback would be much appreciated.

\$\endgroup\$
0

    1 Answer 1

    5
    \$\begingroup\$

    The intent here is that an engineer who is not a Bash expert should be able to readily maintain the code some months down the road.

    I do not know much about bash

    You might be that future maintenance engineer. Be kind to your future self.

    And thank you for that nice shebang.

    true

    get_placement() { while : 

    Please don't do that.

    The Bourne shell has a long and sordid history which involves various (almost) work-alikes. The special builtin : colon operator used to be important when writing scripts.

    Nowadays, please just write while true.

    It's not like we're going to fork off a /usr/bin/true child. The shebang explained that we're targeting a shell, bash, which according to $ type true offers true as a shell builtin. Using arcane punctuation makes it harder for engineers to google for relevant technical advice. Using words (true) lets polyglot engineers leverage their knowledge from other environments into this one.

    shellcheck

    Code review submissions should lint clean before being submitted. When writing shell code it is easy to produce a result, but it is a minor challenge to make the script correct, due to quoting concerns and other minutiae.

    Lint this code, and follow the linter's advice, so it lints clean. 'Nuff said.

    spurious default

    I don't understand this line:

    draw_board() { ... echo "${1:- }|${2:- }|${3:- }" 

    That is, why are we defaulting to SPACE?

    It seems that only [XO-] will ever be passed in. I am reluctant to support defaulting which is never used, out of concern that it may "paper over" some code defect that may be injected in future.

    char vs int; magic constant

     digit_pattern="^[0-$((BOARD_SIZE-1))]\$" 

    One can play tic-tac-toe on boards of various sizes, including 2×2, 3×3, 4×4, ....

    The BOARD_SIZE constant is lovely and I thank you for it.

    Here, the digit_pattern expression is tightly coupled to the ... = 9 assignment. It precludes using a larger board, as then we'd need to worry about multi-digit labels for the squares.

    The key difficulty is that we're validating against string (really char) values, instead of against integer values. I am sad that we don't exit with an error code if this line encounters a "large" board size.

    zero- vs one- origin

    The review context showed a board with squares labeled 0 .. 8, which is very helpful. I'm surprised the game never displays that to the player.

    Consider using 1 .. 9 instead, even if that means allocating storage for an unused zeroth square. Then interpreting a bash expression like $8 would be more straightforward, it would have a more obvious correspondence to a certain square.

    side effect comments

    # sets $ended to whether the game has ended and $winner to the winner get_winner() { 

    Thank you for explaining a non-obvious part of the contract, it's very helpful.

    This function is Too Long, as one cannot read all of it at once, it needs vertical scrolling to review it. You could e.g. easily Extract Helper by writing a check_for_winner function.

    On which topic, the check is entirely too tedious. Better to (roughly) loop over 159 357 123 147.

    Also, the vertical check is incorrect, leading to prompts like this:

    -|O|X -|O|X -|O|X Please enter a valid move (0-8) 

    The shift 3 doesn't interact with the column check as you wanted it to. The row check works fine.

    test suite

    There isn't one, and you clearly need it, given the column check code defect.

    Write down some example move sequences, and the expected winner outcome and board state. Automating this will allow you to view Green bar before each commit.

    decomposition

    I will point out that this is just beautiful:

    do draw_board ${board[@]} get_placement ${board[@]} board[$placement]=$current_turn get_winner ${board[@]} 

    Nice names, very clear narrative, looks great.

    UX

    I found it a bit surprising that the prompt doesn't mention whether it's currently the turn of O or X. It's not necessarily obvious, since an invalid input of e.g. 9 keeps current player the same.

    I was surprised that CTRL/D didn't exit the game due to EOF. An interactive player can always resort to CTRL/C. But automated unit tests may want to use redirection from an example game transcript file, and so reliably terminating on EOF might become an important requirement.


    This code only achieves a subset of its design goals.

    I would not be willing to delegate or accept maintenance tasks on it in its current form.

    \$\endgroup\$
    2
    • \$\begingroup\$Do you recommend any good ways of automating the unit tests that you mentioned? I guess I could do something like cat test.txt | ./run.sh, where test.txt is a file of the moves separated by newlines, and check to see if the output is correct, but I'm not sure how I would do that automatically.\$\endgroup\$CommentedApr 3, 2024 at 11:25
    • 1
      \$\begingroup\$Don't call it test.txt, prefer a name like moves_1.txt. And then the piece you're missing is expected output_1.txt. Definitely use diff -u to compare output. The $? exit code gives you Green vs Red bar result. And lines sent to stdout give you a hint about how to fix the code. Consider echoing a line saying "move N", to help diff properly sync up the two. I suppose that echoing "it is X's turn" might suffice for that.\$\endgroup\$
      – J_H
      CommentedApr 3, 2024 at 11:34

    Start asking to get answers

    Find the answer to your question by asking.

    Ask question

    Explore related questions

    See similar questions with these tags.