# Smallest unique number KoTH

Create a bot to choose the smallest unique number.

(Based on a psychology experiment I heard about many years ago but haven't been able to track down again.)

### Rules

• Each game will consist of 10 randomly selected bots playing 1000 rounds.
• Each round, all bots select an integer from 1 to 10 (inclusive). Any bots that choose the same value will be be excluded, and the remaining bot with the smallest value will receive a point.
• In the event that no bot picks a unique value, no points will be awarded.
• At the end of 1000 rounds, the bot with the most points (or all bots tied with the most points) wins the game.
• The tournament will last 200 * (number of players) games.
• The bot with the highest win percentage wins the tournament.

### Specifications

Bots must be Python 3 classes and must implement two methods: select and update.
Bots will be constructed with an index.
select is passed no arguments and returns the bot's choice for the current round.
update is passed a list of the choices made by each bot in the previous round.

### Example

class Lowball(object):
def __init__(self, index):
# Initial setup happens here.
self.index = index
def select(self):
# Decision-making happens here.
return 1
def update(self, choices):
# Learning about opponents happens here.
# Note that choices[self.index] will be this bot's choice.
pass


### Controller

import numpy as np

from bots import allBotConstructors
allIndices = range(len(allBotConstructors))
games = {i: 0 for i in allIndices}
wins = {i: 0 for i in allIndices}

for _ in range(200 * len(allBotConstructors)):
# Choose players.
playerIndices = np.random.choice(allIndices, 10, replace=False)
players = [allBotConstructors[j](i) for i, j in enumerate(playerIndices)]

scores = [0] * 10
for _ in range(1000):
# Let everyone choose a value.
choices = [bot.select() for bot in players]
for bot in players:
bot.update(choices[:])

# Find who picked the best.
unique = [x for x in choices if choices.count(x) == 1]
if unique:
scores[choices.index(min(unique))] += 1

# Update stats.
for i in playerIndices:
games[i] += 1
bestScore = max(scores)
for i, s in enumerate(scores):
if s == bestScore:
wins[playerIndices[i]] += 1

winRates = {i: wins[i] / games[i] for i in allIndices}
for i in sorted(winRates, key=lambda i: winRates[i], reverse=True):
print('{:>40}: {:.4f} ({}/{})'.format(allBotConstructors[i], winRates[i], wins[i], games[i]))


• No bot will play in a game against itself.
• In the unlikely event that a bot is included in less than 100 games, the tournament will be rerun.
• Bots may store state between rounds, but not between games.
• Accessing the controller or other bots is not allowed.
• The number of games and number of rounds per game are subject to increase if the results are too variable.
• Any bots that raise errors or give invalid responses (non-ints, values outside [1, 10], etc.) will be disqualified, and the tournament will be rerun without them.
• There is no time limit for rounds, but I may implement one if bots take too long to think.
• There is no limit on the number of submissions per user.
• The deadline for submissions is 23:59:59 UTC on Friday, September 28. The tournament is now closed for submissions.

### Results

                BayesBot: 0.3998 (796/1991)
WhoopDiScoopDiPoop: 0.3913 (752/1922)
PoopDiScoopty: 0.3216 (649/2018)
Water: 0.3213 (660/2054)
Lowball: 0.2743 (564/2056)
Saboteur: 0.2730 (553/2026)
OneUpper: 0.2640 (532/2015)
StupidGreedyOne: 0.2610 (516/1977)
SecondSaboteur: 0.2492 (492/1974)
T42T: 0.2407 (488/2027)
T4T: 0.2368 (476/2010)
OpportunityBot: 0.2322 (454/1955)
TheGeneral: 0.1932 (374/1936)
FindRepeats: 0.1433 (280/1954)
MinWin: 0.1398 (283/2025)
LazyStalker: 0.1130 (226/2000)
FollowBot: 0.1112 (229/2060)
Assassin: 0.1096 (219/1999)
MostlyAverage: 0.0958 (194/2024)
UnchosenBot: 0.0890 (174/1955)
Raccoon: 0.0868 (175/2015)
Equalizer: 0.0831 (166/1997)
AvoidConstantBots: 0.0798 (158/1980)
WeightedPreviousUnchosen: 0.0599 (122/2038)
BitterBot: 0.0581 (116/1996)
Profiteur: 0.0564 (114/2023)
HistoryBot: 0.0425 (84/1978)
ThreeFourSix: 0.0328 (65/1984)
Stalker: 0.0306 (61/1994)
Unpopulist: 0.0186 (37/1994)
PoissonsBot: 0.0177 (35/1978)
RaccoonTriangle: 0.0168 (33/1964)
LowHalfRNG: 0.0134 (27/2022)
VictoryPM1: 0.0109 (22/2016)
TimeWeighted: 0.0079 (16/2021)
TotallyLost: 0.0077 (15/1945)
OneTrackMind: 0.0065 (13/1985)
LuckySeven: 0.0053 (11/2063)
FinalCountdown: 0.0045 (9/2000)
Triangle: 0.0039 (8/2052)
LeastFrequent: 0.0019 (4/2067)
Fountain: 0.0015 (3/1951)
PlayerCycle: 0.0015 (3/1995)
Cycler: 0.0010 (2/1986)
SecureRNG: 0.0010 (2/2032)
SneakyNiner: 0.0005 (1/2030)
I_Like_Nines: 0.0000 (0/1973)

• @Mnemonic Any news? Oct 2 '18 at 20:43
• @Herohtar I set it running before I left for work. With any luck, it should be done when I get home.
– user48543
Oct 5 '18 at 15:16
• @Mnemonic Has it finished yet? Oct 10 '18 at 20:16
• @Justin It's running right now, and doesn't seem to be crashing, but I definitely wouldn't mind the help if this run fails.
– user48543
Oct 11 '18 at 13:24
• @MihailMalostanidis Create a file called bots.py in the same directory containing all the bots. At the end, create a list of the constructors: allBotConstructors = [Lowball, BayesBot, ...]
– user48543
Nov 16 '18 at 14:36

## MostlyAverage

import random
import math

class MostlyAverage(object):
def __init__(self, index):
self.total = 0
self.count = 0
def select(self):
returner = []
if self.count > 0:
avg = math.floor(self.total / self.count)
if avg > 1:
returner.append(avg-1)
if avg < 10:
returner.append(avg+1)
returner.append(avg)
returner.append(avg)
else:
returner.append(1)
return returner[random.randint(0, len(returner)-1)]
def update(self, choices):
for x in range(11):
if choices.count(x) == 1:
self.total += x
self.count += 1
break


Keeps track of the average winning number and returns a number close to it.

## I Like Nines

This bot is bored, and doesn't like mind games. So they choose a random number every turn. However, they only have a d16 and don't want to disrupt the game to go find a d10 - so they choose 9 anytime they roll greater than 10!

EDIT: Just to be contrary, if own index is 9, the bot will instead prefer 6 when they roll greater than 10.

import random

class I_Like_Nines(object):
def __init__(self, index):
self.index = index

def select(self):
r = 0
# 4 bits are required to code 1-10 ([0-9] + 1, [0b0000 - 0b1001] + 1)
for i in range(0, 4):
# flip a coin. Puts a 1 in this bit place 50% of the time
if random.random() >= .50:
r += 2 ** i
# if your random bit assigning has produced a number outside the range 1-10, prefer 9
if not (0 < r < 11):
# when you are Bot #9, prefer 6
if self.index == 9:
r = 6
else:
r = 9
return r

def update(self, choices):
pass


# BitterBot

Chooses randomly for 100 rounds, then targets the bot with the most wins (besides itself) to that point and mimics it for the rest of the game.

import random

class BitterBot(object):
def update_score(self, choices):
for element in sorted(choices):
if choices.count(element) == 1:
self.scores[choices.index(element)] += 1
return
return

def choose_target(self):
other_bot_scores = list(self.scores)
del(other_bot_scores[self.index])
return self.scores.index(max(other_bot_scores))

def __init__(self, index):
self.scores = [0] * 10
self.index = index
self.round_num = 0
self.target_index = 0
self.target_choice = 0

def select(self):
if self.round_num <= 100:
return random.choice(range(1,5))
else:
return self.target_choice

def update(self, choices):
self.round_num += 1
if self.round_num < 100:
self.update_score(choices)
elif self.round_num == 100:
self.target_index = self.choose_target()
else:
self.target_choice = choices[self.target_index]


## Equalizer

class Equalizer(object):
def __init__(self,index):
self.chosen = []
self.wins = {}
for x in range(10):
self.chosen.append([])
self.wins[x] = 0
self.choice = 1
def select(self):
return self.choice
def update(self,choices):
winningnum = 0
for x in range(11):
if choices.count(x) == 1:
winningnum = x
break
for x in range(10):
self.chosen[x].append(choices[x])
if choices[x] == winningnum:
self.wins[x] += 1
bests = [key for key in self.wins.keys() if self.wins[key] == max(self.wins.values())]
if len(bests) > 0:
target = bests[random.randint(0, len(bests)-1)]
if len(self.chosen[target]) > 2:
shortlist = self.chosen[target][-20:]
match = self.chosen[target][-1]
match2 = self.chosen[target][-2]
lastmatch = 0
foundit = 0
for k,v in enumerate(shortlist):
if k < 19:
if lastmatch == 1:
if shortlist[k] == match:
lastmatch = 2
else:
lastmatch = 0
elif lastmatch == 2:
self.choice = v
foundit = 1
break
elif shortlist[k] == match2:
lastmatch = 1
if foundit == 0:
lastmatch = 0
for k,v in enumerate(shortlist):
if k < 19:
if lastmatch == 1:
self.choice = v
foundit = 1
break
elif shortlist[k] == match:
lastmatch = 1


Tries to keep the scores in the lead as even as possible by attempting to match what the first place bot will play, looking for patterns in their recent selections. It works on some bot combinations better than others, but I have seen many games within one/two points or even tied at the end.

# ThreeFourSix

Start with 6, then randomly pick one of 3, 4, 6, excluding the one picked in the previous round.

Seeing other (random) bots picking 1 or 2 each round and picking values < 5 or lower values more often, hopefully using 3, 4 and 6 is a good combination.

class ThreeFourSix(object):
def __init__(self, index):
self.index = index
self.guess = 6
def select(self):
return self.guess
def update(self, choices):
threeFourSix = [3, 4, 6]
threeFourSix.remove(choices[self.index])
self.guess = threeFourSix[random.randint(0, 1)]


# Victory Plus Minus One

Randomly picks one above or below the last victory number.

import random

class VictoryPM1(object):
def __init__(self, index):
self.index = index
self.victory = 0

def select(self):
if self.victory < 1 or self.victory > 10:
return 1
elif random.randint(0, 1):
if self.victory < 2:
return 2
else:
return self.victory - 1
else:
if self.victory > 9:
return 9
else:
return self.victory + 1

def update(self, choices):
a = [0] * 11
for b in choices:
a[b] += 1
for i in range(1, 10):
if a[i] == 1:
self.victory = i
return


Nobody likes high numbers but somebody might decide to go for 10 as the least obvious pick. Avoid those tricksters and sneak in with a 9.

class SneakyNiner(object):
def __init__(self, index):
self.index = index
def select(self):
return 9
def update(self, choices):
pass


## FindRepeats

class FindRepeats(object):
def __init__(self,index):
self.wins = []
def select(self):
returner = 1
length = len(self.wins)
if length > 10 and self.wins[-1] == self.wins[-6] and self.wins[-2] == self.wins[-7] and self.wins[-3] == self.wins[-8] and self.wins[-4] == self.wins[-9] and self.wins[-5] == self.wins[-10]:
returner = self.wins[-5]
if length > 8 and self.wins[-1] == self.wins[-5] and self.wins[-2] == self.wins[-6] and self.wins[-3] == self.wins[-7] and self.wins[-4] == self.wins[-8]:
returner = self.wins[-4]
if length > 6 and self.wins[-1] == self.wins[-4] and self.wins[-2] == self.wins[-5] and self.wins[-3] == self.wins[-6]:
returner = self.wins[-3]
if length > 4 and self.wins[-1] == self.wins[-3] and self.wins[-2] == self.wins[-4]:
returner = self.wins[-2]
if length > 2 and self.wins[-1] == self.wins[-2]:
returner = self.wins[-1]
return returner
def update(self,choices):
for x in range(11):
if choices.count(x) == 1:
self.wins.append(x)
break


Tries to find patterns in the most recent winning numbers and continue the pattern if it finds one.

# Tit for Tat

Plays 10, then the smallest value plus one last round.

class T4T(object):
def __init__(self, _):
self.next = 10
def select(self):
return self.next
def update(self, choices):
choice = 10
for c in choices:
choice = min(c, choice)
choice += 1
if(choice > 10): choice = 10
self.next = choice

• choose should be select Sep 26 '18 at 6:48
• I would never do something that stupid <3 Sep 26 '18 at 11:00

# Tit for two Tats

T4T, but uses the value from two rounds ago.

class T42T(object):
def __init__(self, _):
self.next = [10, 9]
def select(self):
return self.next.pop(0)
def update(self, choices):
choice = 10
for c in choices:
choice = min(c, choice)
choice += 1
if(choice > 10): choice = 10
self.next.append(choice)


## Unpopulist

import random

class Unpopulist(object):
def __init__(self, index):
self.index = index
self.choices = [0, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
def select(self):
pos = random.randrange(sum(self.choices))
for i in range(1, 11):
if pos < self.choices[i]:
return i
pos -= self.choices[i]
def update(self, choices):
choices[self.index] = 0;
for i in range(1, 11):
if choices.count(i) < 2:
self.choices[i] += 11 - i


Prefers lower numbers as long as they don't get too many collisions.

• You should change choices[i] to self.choices[i] (2x) in select. Sep 25 '18 at 8:21

# WeightedPreviousUnchosen

Selects from the previous round's unchosen numbers. In order, each has the chance to be chosen equal to 1.25 * (the frequency at which they remained unchosen in the next round).

class WeightedPreviousUnchosen(object):

def get_unchosen(self, list):
unique_choices = []
duplicate_choices = []
for choice in list:
if choice not in duplicate_choices:
if choice in unique_choices:
duplicate_choices.append(choice)
unique_choices.remove(choice)
else:
unique_choices.append(choice)
return unique_choices

def __init__(self, index):
self.previous_unchosen = []
self.current_choices = []
self.unchosen_history = []
for n in range (0, 10):
self.unchosen_history.append([0, 0])

def select(self):
current_unchosen = sorted(self.get_unchosen(self.current_choices))
if len(self.previous_unchosen) > 0:
rand = random.random()
for n in range(0, len(current_unchosen) - 1):
option = (self.unchosen_history)[n]
if option[0] != 0:
if ( (option[1] / option[0])*1.25 > rand):
self.previous_unchosen = current_unchosen
return sorted(self.get_unchosen(self.current_choices))[n]

self.previous_unchosen = current_unchosen
return random.choice(range(2,5))

def update(self, choices):
self.current_choices = choices
for n in range(1, 10):
if n in self.previous_unchosen:
self.unchosen_history[n-1][0] += 1
if n not in choices:
self.unchosen_history[n-1][1] += 1


Based on the premise that bots could be missing would-be-winning numbers. Looks like this generally isn't the case, so it isn't performing too well!