How to Play Bulls and Cows with Python

Shian Liao
Dev Genius
Published in
5 min readFeb 14, 2022

--

An old number guessing game with auto solution

Source: https://en.wikipedia.org/wiki/Bulls_and_Cows

Bulls and Cows is a number guessing game that I first had contact over 20 years ago when I was still writing VBA on Excel. I even created an Excel version that still works (I used to go with Eric Liao back in 2001 but switched to my legal name Shian Liao since 2005):

Copyright by author

Inspired by the Wordle game (How to Cheat in Wordle with Python), I decided to revisit this number guessing game, with my new favorite programming language, Python.

Game rule

The game is officially called Bulls and Cows, and it’s usually played by two players, player A makes up a 4-digit number with no duplicate digits, and player B attempt to guess the secret number round by round.

For each guess, player A will provide player B a score in the form of “1 bull 2 cows”, which means out of the 4 digits player B guessed, there is 1 correct digit at the correct position, and 2 correct digits at wrong positions.

For example, if the secret number is 5213, and player B guessed 1234, he’s going to get a “1 bull 2 cows” score because the number 2 is incorrect position, and numbers 1 and 3 are correct but appeared in wrong positions.

The goal is for player B to achieve “4 bulls 0 cow” score, which means he guessed the entire number correctly.

Player A is usually played by computer and “1 bull 2 cows” score is usually written as “1A2B” for short.

Compared with Wordle, Bulls and Cows are actually much harder because there is very limited information provided from each score. For example, in the above 1234 (1 bull 2 cows) case, you won’t know which digit is bull, and which two digits are cows.

And if you want, it can get even harder. For example, instead of 4 digits secret number, you can use 5 or more digits. Instead of 4 digits with no duplicates, you can allow duplicate digits in the number. You can even replace 4-digit numbers with 4-letter words.

Python implementation

Generate a random number

The first logical step is to generate a 4-digit random number like this:

import random
rndnum = random.randint(1000, 9999)

Be aware that random.randint takes a start integer and an end integer and are both including, unlike range . Also You want to start from 1000 in order to exclude any number with less than 4 digits.

However, This could still return numbers with duplicate digits, such as 1111. The following code expanded the random number generation function both invalidity and in versatility:

def gen_rand_num(digits=4, no_leading_zeros=True, no_repeating_digits=True):
if digits>=10:
no_repeating_digits = False
start = 10**(digits-1) if no_leading_zeros else 0
end = 10**digits-1
while True:
rndnum = random.randint(start, end)
rndnum_str = f'{{0:0{digits}d}}'.format(rndnum)
if no_repeating_digits:
if len(set(rndnum_str))==digits:
return rndnum_str
else:
return rndnum_str

There is actually a better way instead of check for validity and generate another number if invalid, by generating a list of valid numbers in advance, with permutation.

Check user input for score

To obtain user input, you can simply use a input method in Python:

for i in range(10):
guess = input(f'Round {i+1}, enter your guess: ')

Then all you need to do is to check user input against the random number generated, digit by digit:

def cal_score_against(guess, against):
rndnum_str = f'{{0:04d}}'.format(int(against))
rndnum_lst = list(rndnum_str)
guess_str = f'{{0:04d}}'.format(int(guess))
guess_lst = list(guess_str)
a = sum([n==rndnum_lst[i] for i, n in enumerate(guess_lst)])
b = sum([n in rndnum_lst and n!=rndnum_lst[i]
for i, n in enumerate(guess_lst)])
return f'{a}A{b}B'

The list comprehensions in above code are more elegant than a for loop, and took advantage of the sum([True, False])=1 feature in Python to calculate the number of hits.

Wrap in a loop

That’s almost all for a simple implementation of Bulls and Cows, let’s wrap it in a loop and give it a try:

import randomdef gen_rand_num(digits=4, no_leading_zeros=True, no_repeating_digits=True):
if digits>=10:
no_repeating_digits = False
start = 10**(digits-1) if no_leading_zeros else 0
end = 10**digits-1
while True:
rndnum = random.randint(start, end)
rndnum_str = f'{{0:0{digits}d}}'.format(rndnum)
if no_repeating_digits:
if len(set(rndnum_str))==digits:
return rndnum_str
else:
return rndnum_str
def cal_score_against(guess, against):
rndnum_str = f'{{0:04d}}'.format(int(against))
rndnum_lst = list(rndnum_str)
guess_str = f'{{0:04d}}'.format(int(guess))
guess_lst = list(guess_str)
a = sum([n==rndnum_lst[i] for i, n in enumerate(guess_lst)])
b = sum([n in rndnum_lst and n!=rndnum_lst[i]
for i, n in enumerate(guess_lst)])
return f'{a}A{b}B'
rndnum = gen_rand_num()
for i in range(10):
guess = input(f'Round {i+1}, enter your guess: ')
score = cal_score_against(guess, rndnum)
if score=='4A0B':
print(f'{score}, you won!')
break
else:
print(score)
else:
print(f'You lose, correct answer is: {numble.get_answer()}')
output:
Round 1, enter your guess: 1023
2A0B
Round 2, enter your guess: 1045
0A1B
Round 3, enter your guess: 4623
2A0B
Round 4, enter your guess: 5723
2A1B
Round 5, enter your guess: 8523
4A0B, you won!

The above 37 lines of code is a simplified implementation of course, and you might have already spotted some hidden bugs, such as nothing is done to deal with improper inputs. But it serves the purpose.

To lazy to play? Try auto guess!

As lazy as myself, of course I won’t just stop here. One auto-guess solution that I can quickly pull up is brute-force by iterating through all possible answers and check them against known scores.

def auto_guess(rndnum):
print('Game started, auto guessing ...')
start = 10**(self.digits-1) if self.no_leading_zeros else 0
end = 10**self.digits-1
guesses = {}
for guess in range(start, end+1):
valid = True
for item, value in guesses.items():
if cal_score_against(item, guess)!=value:
valid = False
break
else:
score = cal_score_against(guess, rndnum)
guesses[guess] = score
if not valid:
continue
if score=='4A0B':
print(f'Round {len(guesses)}, {guess}: {score}, you won!')
break
else:
print(f'Round {len(guesses)}, {guess}: {score}')
output:
Game started, auto guessing ...
Round 1, 1023: 0A2B
Round 2, 2145: 0A2B
Round 3, 3256: 0A2B
Round 4, 4361: 1A1B
Round 5, 4530: 1A1B
Round 6, 4602: 0A0B
Round 7, 5731: 3A0B
Round 8, 5831: 3A0B
Round 9, 5931: 4A0B, you won!

I actually tested all 4536 possible hidden numbers with this approach and it took 2m11s to complete guessing all of them. There are a few of that takes a maximum of 9 rounds to guess, such as the 5931 above. According to Wikipedia, it’s proved that all problems can be solved under 7 steps, and the average minimal steps is 5.21. I consider my brute-force algorithm very successful.

One more thing

It may sound plausible to implement this game with GUI instead of my humble command line version. But just search Bulls and Cows on your App Store, you’ll find tons of free versions. Unless you want to take it up as a challenge to learn something new, it’s not worth the sweat.

--

--