Building Log Hacker

Play it first → You have broken into a server. One of the words on the screen is its password, and you have four tries. Click a word to guess it. Every guess goes in the log, with how many of its letters are the right letter in the right place, and the password has to match every line of the log. Get it, and the next server's password is longer. There is no clock.

This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.

There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page. There are no pictures at all, only words, and four sounds from the playground.

It is the game for Lesson 9 — Files, and it comes after Goblin Hit. It uses the same pieces: functions, global, wait(), onClick with isHovering(), and a scene. If any of those are new, build Goblin Hit first. After this one, try Paddle Ball.

The one big idea: three files

A real hacker would find the files already on the server. This game has no server, so it makes its own, and then reads them back exactly as it would read any file:

  • words.txt — the words the password could be, one on each line.
  • password.txt — the one it is.
  • log.txt — every guess so far, and how close it was.

Every file is opened the same way:

with open("words.txt", "w") as words_file:
    words_file.write("crash\n")

open() opens the file, and as words_file gives it a name for the indented lines underneath. When the indenting ends, the file is closed by itself. The second thing in open() says what you're going to do with it: "w" to write, "r" to read, "a" to add to the end.

Run it on your own computer and the three files appear in the folder your program runs in, where you can open them and look. In the playground they are just as real, but they live inside the page. They last from one Run to the next, and they are gone when you reload.

Step 1 — a file of words

Write eight words into a file, read them back, and put them on the screen.

from kaypy import *

kaypy(width=800, height=600, background=[8, 14, 10])

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)

WORDS = ["crash", "crack", "track", "trace", "trick", "brick", "block",
         "clock"]

# Write the file. with ... as words_file: opens it, and calls it
# words_file for the indented lines underneath. When they end, the file
# is closed by itself. "w" means write: it makes words.txt, or empties it
# if it is already there.
with open("words.txt", "w") as words_file:
    for word in WORDS:
        words_file.write(word + "\n")

# Read it back. "r" means read. A for loop over a file goes round once for
# each line, and line is the line, "\n" and all. strip() takes it off.
words = []
with open("words.txt", "r") as words_file:
    for line in words_file:
        words.append(line.strip())

# The words, down the left-hand side.
add([text("words.txt", size=20), pos(40, 100), color(DIM)])
y = 130
for word in words:
    add([text(word, size=26), pos(50, y), color(GREEN)])
    y = y + 32
  • "w" means write. It makes words.txt, or empties it first if it's already there.
  • write() puts exactly what you give it into the file, and nothing else. It doesn't start a new line for you. "\n" is the end of a line, so word + "\n" is the word and then a new line.
  • "r" means read. for line in words_file goes round once for each line in the file, top to bottom, and each time line is the next one.
  • line still has its "\n" on the end. The first time round it is "crash\n", not "crash". strip() takes it off, along with any spaces at either end.

Break it. Take the + "\n" out of the write() line. There is no error, but there is only one word now: crashcracktracktracetrickbrickblockclock.

The words went into the file one after another with nothing between them, so the file is one line long, and reading it back, the loop finds one line.

Break it. Put the "\n" back, and add one more line after the first with, right at the left edge, not indented:

words_file.write("slash\n")

It stops straight away with

ValueError: I/O operation on closed file.

The file was closed when the indenting ended. words_file still names it, but you can't write to a closed file. Everything you do with a file goes inside its with.

Step 2 — the password, and guessing

One of the words is the password, and it goes in a file of its own. Click a word to guess it.

from kaypy import *

kaypy(width=800, height=600, background=[8, 14, 10])

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)

WORDS = ["crash", "crack", "track", "trace", "trick", "brick", "block",
         "clock"]

# Write the file. with ... as words_file: opens it, and calls it
# words_file for the indented lines underneath. When they end, the file
# is closed by itself. "w" means write: it makes words.txt, or empties it
# if it is already there.
with open("words.txt", "w") as words_file:
    for word in WORDS:
        words_file.write(word + "\n")

# Read it back. "r" means read. A for loop over a file goes round once for
# each line, and line is the line, "\n" and all. strip() takes it off.
words = []
with open("words.txt", "r") as words_file:
    for line in words_file:
        words.append(line.strip())

# One of them is the password, and it goes in a file of its own.
with open("password.txt", "w") as password_file:
    password_file.write(choose(words) + "\n")


def read_password():
    # read() is the whole file, as one string.
    with open("password.txt", "r") as password_file:
        password = password_file.read().strip()
    return password


# The words, down the left-hand side. Each one is something to click:
# area() lets it know when the mouse is over it, and the dictionary gives
# it a value of its own, the word.
add([text("words.txt", size=20), pos(40, 100), color(DIM)])
y = 130
for word in words:
    add([text(word, size=26), pos(50, y), color(GREEN), area(),
         {"word": word}, "word"])
    y = y + 32

message = add([text("", size=36), pos(560, 450), anchor("center"),
               color(GREEN)])


# onClick runs on every click, wherever it is, and a tap on a phone counts
# as a click. isHovering() asks whether the mouse is over a word.
@onClick
def click():
    for row in get("word"):
        if row.isHovering():
            guess(row)
            return


def guess(row):
    if row.word == read_password():
        message.text = "ACCESS GRANTED"
    else:
        message.text = "WRONG"
        row.color = DIM
  • choose(words) picks one of the words at random, and it is written into password.txt.
  • read() hands back the whole file as one string, so read_password() gives "brick\n", and strip() makes it "brick". The program never keeps the password in a variable for long: it reads it from the file every time you guess.
  • Each word has area(), so it knows when the mouse is over it, and {"word": word}, so it remembers its word as row.word.
  • @onClick runs click on every click, wherever it is, and a tap on a phone counts. It goes through the words and guesses the one the mouse is over. A wrong one goes dim.

Break it. In read_password, take .strip() off. Now click every word, one after another. Every one is WRONG, including the password.

Without strip() the password is "brick\n", and "brick" is not the same string. A "\n" doesn't show on the screen, so nothing looks wrong. This is the most common mistake there is with files, and it never makes an error message.

Step 3 — how close, and the log

Guessing at random isn't hacking. Now every guess goes in the log, with how many of its letters are the right letter in the right place.

from kaypy import *

kaypy(width=800, height=600, background=[8, 14, 10])

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)

WORDS = ["crash", "crack", "track", "trace", "trick", "brick", "block",
         "clock"]

# Write the file. with ... as words_file: opens it, and calls it
# words_file for the indented lines underneath. When they end, the file
# is closed by itself. "w" means write: it makes words.txt, or empties it
# if it is already there.
with open("words.txt", "w") as words_file:
    for word in WORDS:
        words_file.write(word + "\n")

# Read it back. "r" means read. A for loop over a file goes round once for
# each line, and line is the line, "\n" and all. strip() takes it off.
words = []
with open("words.txt", "r") as words_file:
    for line in words_file:
        words.append(line.strip())

# One of them is the password, and it goes in a file of its own. And a
# log, with nothing in it yet.
with open("password.txt", "w") as password_file:
    password_file.write(choose(words) + "\n")
with open("log.txt", "w") as log_file:
    log_file.write("")


def read_password():
    # read() is the whole file, as one string.
    with open("password.txt", "r") as password_file:
        password = password_file.read().strip()
    return password


def read_log():
    # Each line of the log is a guess and how many letters it got right,
    # with a comma between: crash,2
    guesses = []
    with open("log.txt", "r") as log_file:
        for line in log_file:
            guesses.append(line.strip())
    return guesses


# How many letters of the guess are the right letter in the right place.
# crash and crack share c, r, a in the same places, so that is 3.
def letters_right(guess, password):
    right = 0
    for i in range(len(password)):
        if guess[i] == password[i]:
            right = right + 1
    return right


# The words, down the left-hand side. Each one is something to click:
# area() lets it know when the mouse is over it, and the dictionary gives
# it a value of its own, the word.
add([text("words.txt", size=20), pos(40, 100), color(DIM)])
add([text("log.txt", size=20), pos(330, 100), color(DIM)])
y = 130
for word in words:
    add([text(word, size=26), pos(50, y), color(GREEN), area(),
         {"word": word}, "word"])
    y = y + 32

message = add([text("", size=36), pos(560, 450), anchor("center"),
               color(GREEN)])


# onClick runs on every click, wherever it is, and a tap on a phone counts
# as a click. isHovering() asks whether the mouse is over a word.
@onClick
def click():
    for row in get("word"):
        if row.isHovering():
            guess(row)
            return


# Read log.txt and put every line of it on the screen. The old lines are
# taken away first, so each guess is shown once.
def show_log():
    destroyAll("entry")
    y = 130
    for guess in read_log():
        # "crash,2" becomes ["crash", "2"]: split(",") cuts it up at the
        # comma.
        parts = guess.split(",")
        add([text("> " + parts[0], size=24), pos(340, y), color(GREEN),
             "entry"])
        add([text(parts[1] + "/5 right", size=24), pos(500, y), color(DIM),
             "entry"])
        y = y + 32


def guess(row):
    password = read_password()
    right = letters_right(row.word, password)

    # The guess goes on the end of the log. "a" means add: it keeps what
    # is there and writes after it, where "w" would empty it.
    with open("log.txt", "a") as log_file:
        log_file.write(row.word + "," + str(right) + "\n")
    show_log()

    if row.word == password:
        message.text = "ACCESS GRANTED"
    else:
        row.color = DIM
  • letters_right goes along the password one letter at a time. range(len(password)) is 0, 1, 2, 3, 4 for a five-letter password, and guess[i] is the letter at that place. If the guess has the same letter in the same place, that's one more right. crash and crack share c, r and a in the same places, so that's 3.
  • "a" means add. It keeps what is in the file and writes after it. Every guess goes on the end of log.txt as a line like crash,3.
  • show_log() reads the whole log back and puts it on the screen. split(",") cuts "crash,3" at the comma into ["crash", "3"]. It takes the old lines away first, with destroyAll("entry"), so each guess is shown once.
  • At the start, the log is opened with "w" and nothing written, which empties it, so it doesn't still hold the guesses from last time you pressed Run.

Now you can work it out. Say you guess clock and get 2/5. The password has exactly two letters where clock has them. track shares only the ck at the end with clock, so it could be the password. crash shares only the c at the start, so it can't. Every line of the log rules words out.

Break it. In guess, change open("log.txt", "a") to open("log.txt", "w"). There is no error, but the log only ever shows one line: the last guess. "w" empties the file every time, so every guess wipes out the one before.

Step 4 — four tries

Now it's a game. You get four tries, and then you're locked out. Writing the files goes into a function, and the whole game goes into a scene, so it can start again with new files.

from kaypy import *

kaypy(width=800, height=600, background=[8, 14, 10])

WORDS = ["admin", "login", "guest", "pixel", "cyber", "virus", "token",
         "cache", "patch", "debug", "shell", "stack", "crash", "crack",
         "track", "trace", "trick", "brick", "block", "clock", "click",
         "flash", "stash", "slash"]


TRIES = 4              # how many guesses you get

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)
BRIGHT = rgb(200, 255, 210)

def write_files(how_many):
    # Pick different words until there are enough: a word that is already
    # picked is skipped.
    words = []
    while len(words) < how_many:
        word = choose(WORDS)
        if word not in words:
            words.append(word)

    with open("words.txt", "w") as words_file:
        for word in words:
            words_file.write(word + "\n")

    # One of them is the password.
    with open("password.txt", "w") as password_file:
        password_file.write(choose(words) + "\n")

    # A new log, with nothing in it yet. "w" empties it.
    with open("log.txt", "w") as log_file:
        log_file.write("")


def read_words():
    words = []
    with open("words.txt", "r") as words_file:
        for line in words_file:
            words.append(line.strip())
    return words


def read_password():
    # read() is the whole file, as one string.
    with open("password.txt", "r") as password_file:
        password = password_file.read().strip()
    return password


def read_log():
    # Each line of the log is a guess and how many letters it got right,
    # with a comma between: crash,2
    guesses = []
    with open("log.txt", "r") as log_file:
        for line in log_file:
            guesses.append(line.strip())
    return guesses


# How many letters of the guess are the right letter in the right place.
# crash and crack share c, r, a in the same places, so that is 3.

def letters_right(guess, password):
    right = 0
    for i in range(len(password)):
        if guess[i] == password[i]:
            right = right + 1
    return right


tries_left = TRIES
playing = True
over = False


@scene("server")
def server():
    global tries_left, playing, over

    # A new game: new files, written and then read.
    write_files(8)
    words = read_words()

    tries_left = TRIES
    playing = True
    over = False

    # ------------------------------------------------------- the screen

    add([text("SERVER", size=28), pos(20, 20), color(GREEN)])
    tries_label = add([text("TRIES LEFT " + str(TRIES), size=24),
                       pos(780, 24), anchor("topright"), color(GREEN)])

    add([text("words.txt", size=20), pos(40, 100), color(DIM)])
    add([text("log.txt", size=20), pos(330, 100), color(DIM)])

    # The words, read from words.txt. Each one is something to click:
    # area() lets it know when the mouse is over it, and the dictionary
    # gives it two values of its own -- the word, and whether it has been
    # tried yet.
    y = 130
    for word in words:
        add([text(word, size=26), pos(50, y), color(GREEN), area(),
             {"word": word, "tried": False}, "word"])
        y = y + 32

    message = add([text("", size=36), pos(560, 450), anchor("center"),
                   color(GREEN), z(10)])
    under = add([text("", size=22), pos(560, 495), anchor("center"),
                 color(GREEN), z(10)])

    # ------------------------------------------------------------ the log
    #
    # Read log.txt and put every line of it on the screen. The old lines
    # are taken away first, so each guess is shown once.

    def show_log():
        destroyAll("entry")
        y = 130
        for guess in read_log():
            # "crash,2" becomes ["crash", "2"]: split(",") cuts it up at
            # the comma.
            parts = guess.split(",")
            add([text("> " + parts[0], size=24), pos(340, y), color(GREEN),
                 "entry"])
            add([text(parts[1] + "/" + "5 right", size=24),
                 pos(500, y), color(DIM), "entry"])
            y = y + 32

    # ------------------------------------------------------------ guessing

    # A word lights up while the mouse is over it, unless it has been tried.
    @onUpdate("word")
    def light_up(row):
        if row.tried:
            row.color = DIM
        elif row.isHovering() and playing:
            row.color = BRIGHT
        else:
            row.color = GREEN

    # onClick runs on every click, wherever it is, and a tap on a phone
    # counts as a click. isHovering() asks whether the mouse is over a word,
    # which needs area() on it.
    @onClick
    def click():
        if not playing:
            return
        for row in get("word"):
            if row.isHovering() and not row.tried:
                guess(row)
                return

    def guess(row):
        global tries_left

        row.tried = True
        password = read_password()
        right = letters_right(row.word, password)

        # The guess goes on the end of the log. "a" means add: it keeps
        # what is there and writes after it, where "w" would empty it.
        with open("log.txt", "a") as log_file:
            log_file.write(row.word + "," + str(right) + "\n")
        show_log()

        if row.word == password:
            granted(password)
            return

        tries_left = tries_left - 1
        tries_label.text = "TRIES LEFT " + str(tries_left)
        shake(4)
        if tries_left == 0:
            locked_out(password)

    # --------------------------------------------------- in, or locked out

    def granted(password):
        global playing

        playing = False
        message.text = "ACCESS GRANTED"
        under.text = "the password was " + password
        wait(1, ready_again)

    def locked_out(password):
        global playing

        playing = False
        shake(12)

        message.text = "LOCKED OUT"
        message.color = RED
        under.text = "it was " + password

        # A second before you can start again, so a click meant for a word
        # does not skip past what happened.
        wait(1, ready_again)

    def ready_again():
        global over

        over = True
        add([text("press SPACE or click to start again", size=22),
             pos(560, 540), anchor("center"), color(GREEN)])

    def again():
        if over:
            go("server")

    onKeyPress("space", again)
    onClick(again)


go("server")
  • write_files(8) writes all three files. Its while loop picks words until there are eight different ones: a word already picked is skipped. A for loop would go round exactly eight times and could pick the same word twice.
  • Everything is inside @scene("server"), so go("server") starts it again from the top: new words, a new password, an empty log, and four tries.
  • Each word has two values of its own now, {"word": word, "tried": False}. Once you've guessed a word it is tried, so it goes dim and clicking it again does nothing. A word you haven't tried lights up when the mouse is over it.
  • tries_left goes down by one for every wrong guess. At 0 it's locked_out: the game says what the password was, and waits a second before you can start again, so a click meant for a word doesn't start a new game before you've seen it.
  • global: tries_left, playing and over are made at the top of the program and changed inside functions, so every function that changes one says global first.

Break it. In write_files, take out the last two lines, the with that empties the log. Play a game to the end and start again. The new game's log already has the last game's guesses in it, and how close they were was worked out against the last game's password, so they tell you nothing about this one.

A file stays until something changes it. That's what files are for, and it's why the log has to be emptied on purpose.

Step 5 — harder servers

Get in, and there is another server, with more words and a longer password.

from kaypy import *

kaypy(width=800, height=600, background=[8, 14, 10])

WORDS = ["admin", "login", "guest", "pixel", "cyber", "virus", "token",
         "cache", "patch", "debug", "shell", "stack", "crash", "crack",
         "track", "trace", "trick", "brick", "block", "clock", "click",
         "flash", "stash", "slash",
         "server", "binary", "cookie", "kernel", "packet", "router",
         "script", "secret", "socket", "pocket", "rocket", "locket",
         "system", "matrix", "shadow", "access", "backup", "buffer",
         "hacker", "logger", "loader", "locker", "master", "poster",
         "account", "browser", "network", "malware", "monitor", "program",
         "console", "hacking", "decrypt", "encrypt", "virtual", "digital",
         "gateway", "storage", "process", "compile", "install", "payload",
         "cracker", "tracker", "packets", "sockets", "rockets", "pockets"]


TRIES = 4              # how many guesses you get on each server

WORDS_START = 8        # how many words the first server has
WORDS_MORE = 1         # ...one more on each server after it
WORDS_MOST = 14        # ...and never more than this, or they run off the screen

LENGTH_START = 5       # how long the first server's password is
LENGTH_MOST = 7        # every two servers it is one longer, up to this

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)
BRIGHT = rgb(200, 255, 210)

def write_files(length, how_many):
    # Every word in WORDS that is the right length.
    fits = []
    for word in WORDS:
        if len(word) == length:
            fits.append(word)

    # The while loop below picks different words until it has enough. Ask
    # it for more than there are and it could never finish: it would go
    # round for ever, and the game would freeze. So never ask for more.
    if how_many > len(fits):
        how_many = len(fits)

    words = []
    while len(words) < how_many:
        word = choose(fits)
        if word not in words:
            words.append(word)

    with open("words.txt", "w") as words_file:
        for word in words:
            words_file.write(word + "\n")

    # One of them is the password.
    with open("password.txt", "w") as password_file:
        password_file.write(choose(words) + "\n")

    # A new log, with nothing in it yet. "w" empties it.
    with open("log.txt", "w") as log_file:
        log_file.write("")


def read_words():
    words = []
    with open("words.txt", "r") as words_file:
        for line in words_file:
            words.append(line.strip())
    return words


def read_password():
    # read() is the whole file, as one string.
    with open("password.txt", "r") as password_file:
        password = password_file.read().strip()
    return password


def read_log():
    # Each line of the log is a guess and how many letters it got right,
    # with a comma between: crash,2
    guesses = []
    with open("log.txt", "r") as log_file:
        for line in log_file:
            guesses.append(line.strip())
    return guesses


# How many letters of the guess are the right letter in the right place.
# crash and crack share c, r, a in the same places, so that is 3.

def letters_right(guess, password):
    right = 0
    for i in range(len(password)):
        if guess[i] == password[i]:
            right = right + 1
    return right


tries_left = TRIES
playing = True
over = False


@scene("server")
def server(number):
    global tries_left, playing, over

    # A new server: its own files, written and then read. The password is
    # one letter longer every two servers.
    length = LENGTH_START + (number - 1) // 2
    if length > LENGTH_MOST:
        length = LENGTH_MOST
    how_many = WORDS_START + (number - 1) * WORDS_MORE
    if how_many > WORDS_MOST:
        how_many = WORDS_MOST
    write_files(length, how_many)
    words = read_words()

    tries_left = TRIES
    playing = True
    over = False

    # ------------------------------------------------------- the screen

    add([text("SERVER " + str(number), size=28), pos(20, 20), color(GREEN)])
    tries_label = add([text("TRIES LEFT " + str(TRIES), size=24),
                       pos(780, 24), anchor("topright"), color(GREEN)])

    add([text("words.txt", size=20), pos(40, 100), color(DIM)])
    add([text("log.txt", size=20), pos(330, 100), color(DIM)])

    # The words, read from words.txt. Each one is something to click:
    # area() lets it know when the mouse is over it, and the dictionary
    # gives it two values of its own -- the word, and whether it has been
    # tried yet.
    y = 130
    for word in words:
        add([text(word, size=26), pos(50, y), color(GREEN), area(),
             {"word": word, "tried": False}, "word"])
        y = y + 32

    message = add([text("", size=36), pos(560, 450), anchor("center"),
                   color(GREEN), z(10)])
    under = add([text("", size=22), pos(560, 495), anchor("center"),
                 color(GREEN), z(10)])

    # ------------------------------------------------------------ the log
    #
    # Read log.txt and put every line of it on the screen. The old lines
    # are taken away first, so each guess is shown once.

    def show_log():
        destroyAll("entry")
        y = 130
        for guess in read_log():
            # "crash,2" becomes ["crash", "2"]: split(",") cuts it up at
            # the comma.
            parts = guess.split(",")
            add([text("> " + parts[0], size=24), pos(340, y), color(GREEN),
                 "entry"])
            add([text(parts[1] + "/" + str(length) + " right", size=24),
                 pos(500, y), color(DIM), "entry"])
            y = y + 32

    # ------------------------------------------------------------ guessing

    # A word lights up while the mouse is over it, unless it has been tried.
    @onUpdate("word")
    def light_up(row):
        if row.tried:
            row.color = DIM
        elif row.isHovering() and playing:
            row.color = BRIGHT
        else:
            row.color = GREEN

    # onClick runs on every click, wherever it is, and a tap on a phone
    # counts as a click. isHovering() asks whether the mouse is over a word,
    # which needs area() on it.
    @onClick
    def click():
        if not playing:
            return
        for row in get("word"):
            if row.isHovering() and not row.tried:
                guess(row)
                return

    def guess(row):
        global tries_left

        row.tried = True
        password = read_password()
        right = letters_right(row.word, password)

        # The guess goes on the end of the log. "a" means add: it keeps
        # what is there and writes after it, where "w" would empty it.
        with open("log.txt", "a") as log_file:
            log_file.write(row.word + "," + str(right) + "\n")
        show_log()

        if row.word == password:
            granted(password)
            return

        tries_left = tries_left - 1
        tries_label.text = "TRIES LEFT " + str(tries_left)
        shake(4)
        if tries_left == 0:
            locked_out(password)

    # --------------------------------------------------- in, or locked out

    def granted(password):
        global playing

        playing = False
        message.text = "ACCESS GRANTED"
        under.text = "the password was " + password
        wait(2, next_server)

    def next_server():
        go("server", number + 1)

    def locked_out(password):
        global playing

        playing = False
        shake(12)

        message.text = "LOCKED OUT"
        message.color = RED
        under.text = "it was " + password + " -- servers hacked: " + str(number - 1)

        # A second before you can start again, so a click meant for a word
        # does not skip past what happened.
        wait(1, ready_again)

    def ready_again():
        global over

        over = True
        add([text("press SPACE or click to start again", size=22),
             pos(560, 540), anchor("center"), color(GREEN)])

    def again():
        if over:
            go("server", 1)

    onKeyPress("space", again)
    onClick(again)


go("server", 1)
  • WORDS has words of three lengths, all mixed together. write_files is told the length and picks out only the words that are that long: if len(word) == length. A password and its guesses always have the same number of letters.
  • The scene is given the server's number: def server(number), and go("server", number + 1) goes to the next one. (number - 1) // 2 goes up by one every two servers, so servers 1 and 2 have five-letter words, 3 and 4 six, and from 5 on, seven. Every server has one more word than the last, up to WORDS_MOST, because fourteen is all that fit on the screen.
  • There are 24 words of each length. Ask write_files for more than there are and its while loop could never find them: it would go round for ever, and the game would freeze. So it never asks for more than len(fits).

Break it. In write_files, take out if len(word) == length: and move fits.append(word) back to line up with it, so every word fits. Now the words on the screen are all different lengths. Guess a word that is shorter than the password and it stops with

IndexError: string index out of range

on if guess[i] == password[i]:. letters_right goes along the password, and the guess runs out of letters first. guess[5] of a five-letter word is a place that isn't there.

Step 6 — the whole game

Sounds, the best score, and, the first time you play, a message that says what to do. This is the game you played, line for line.

  • The best score is the most servers you got into. setData saves it and getData reads it back, even after the page is closed.
  • say() puts the instructions up the first time and waits until you close them. told remembers that it has been said, so it isn't said again every server.
"""Log Hacker -- guess the server's password. Every guess tells you how close.

    click a word to try it as the password

You have broken into a server. One of the words on the left is its
password, and you have four tries. Every wrong guess goes in the log on
the right, with how many of its letters are the right letter in the
right place. Use that to rule words out: the password has to match every
line in the log. Get it, and the next server's password is longer.

There is no clock. Take as long as you like.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the sounds
    2. the words
    3. the settings
    4. writing the server's files
    5. reading them back
    6. how close a guess is
    7. the game scene: the words, guessing, the log
    8. start the game

This is the game for files. A real hacker would find the files already on
the server. This game has to make its own first, and then it reads them
back exactly as it would read any file. There are three:

    words.txt       the words it could be, one on each line
    password.txt    the one it is
    log.txt         every guess so far, and how close it was

Every file is opened with `with`, which closes it again by itself at the
end of the indented lines underneath.
"""

from kaypy import *

# ----------------------------------------------------------------- 1. setup

kaypy(width=800, height=600, background=[8, 14, 10])

loadSound("blip", "sounds/small_beep.wav")
loadSound("granted", "sounds/rising_beep.wav")
loadSound("wrong", "sounds/thump.wav")
loadSound("locked", "sounds/small_boom.wav")


# --------------------------------------------------------------- 2. the words
#
# Every password is one of these. A server only uses words of one length,
# so they are mixed together here and picked out by length later. A lot of
# them look alike -- crash, crack, track -- which is what makes how close a
# guess was worth knowing.

WORDS = ["admin", "login", "guest", "pixel", "cyber", "virus", "token",
         "cache", "patch", "debug", "shell", "stack", "crash", "crack",
         "track", "trace", "trick", "brick", "block", "clock", "click",
         "flash", "stash", "slash",
         "server", "binary", "cookie", "kernel", "packet", "router",
         "script", "secret", "socket", "pocket", "rocket", "locket",
         "system", "matrix", "shadow", "access", "backup", "buffer",
         "hacker", "logger", "loader", "locker", "master", "poster",
         "account", "browser", "network", "malware", "monitor", "program",
         "console", "hacking", "decrypt", "encrypt", "virtual", "digital",
         "gateway", "storage", "process", "compile", "install", "payload",
         "cracker", "tracker", "packets", "sockets", "rockets", "pockets"]


# -------------------------------------------------------------- 3. settings
#
# Everything else you would want to change is here. Change one at a time
# and play it.

TRIES = 4              # how many guesses you get on each server

WORDS_START = 8        # how many words the first server has
WORDS_MORE = 1         # ...one more on each server after it
WORDS_MOST = 14        # ...and never more than this, or they run off the screen

LENGTH_START = 5       # how long the first server's password is
LENGTH_MOST = 7        # every two servers it is one longer, up to this

GREEN = rgb(90, 230, 120)
DIM = rgb(50, 110, 65)
BRIGHT = rgb(200, 255, 210)

# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it.
BEST_KEY = "log_hacker_best"


# --------------------------------------------- 4. writing the server's files
#
# with open("words.txt", "w") as words_file: opens the file, and calls it
# words_file for the indented lines underneath. When they end, the file is
# closed by itself. "w" means write: it makes the file, or empties it if
# it is already there.
#
# "\n" is the end of a line. write() does not add one for you, so without
# it every word would end up on the same line: crashcracktrack.

def write_files(length, how_many):
    # Every word in WORDS that is the right length.
    fits = []
    for word in WORDS:
        if len(word) == length:
            fits.append(word)

    # The while loop below picks different words until it has enough. Ask
    # it for more than there are and it could never finish: it would go
    # round for ever, and the game would freeze. So never ask for more.
    if how_many > len(fits):
        how_many = len(fits)

    words = []
    while len(words) < how_many:
        word = choose(fits)
        if word not in words:
            words.append(word)

    with open("words.txt", "w") as words_file:
        for word in words:
            words_file.write(word + "\n")

    # One of them is the password.
    with open("password.txt", "w") as password_file:
        password_file.write(choose(words) + "\n")

    # A new log, with nothing in it yet. "w" empties it.
    with open("log.txt", "w") as log_file:
        log_file.write("")


# --------------------------------------------------- 5. reading them back
#
# "r" means read. A for loop over a file goes round once for each line,
# and line is the line, "\n" and all. strip() takes the "\n" off --
# without it, "crash\n" is not "crash", and no guess would ever be right.

def read_words():
    words = []
    with open("words.txt", "r") as words_file:
        for line in words_file:
            words.append(line.strip())
    return words


def read_password():
    # read() is the whole file, as one string.
    with open("password.txt", "r") as password_file:
        password = password_file.read().strip()
    return password


def read_log():
    # Each line of the log is a guess and how many letters it got right,
    # with a comma between: crash,2
    guesses = []
    with open("log.txt", "r") as log_file:
        for line in log_file:
            guesses.append(line.strip())
    return guesses


# ------------------------------------------------- 6. how close a guess is
#
# How many letters of the guess are the right letter in the right place.
# crash and crack share c, r, a in the same places, so that is 3.

def letters_right(guess, password):
    right = 0
    for i in range(len(password)):
        if guess[i] == password[i]:
            right = right + 1
    return right


# ------------------------------------------------------- 7. the game scene
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".

tries_left = TRIES
playing = True
over = False
told = False


@scene("server")
def server(number):
    global tries_left, playing, over, told

    # A new server: its own files, written and then read. The password is
    # one letter longer every two servers.
    length = LENGTH_START + (number - 1) // 2
    if length > LENGTH_MOST:
        length = LENGTH_MOST
    how_many = WORDS_START + (number - 1) * WORDS_MORE
    if how_many > WORDS_MOST:
        how_many = WORDS_MOST
    write_files(length, how_many)
    words = read_words()

    tries_left = TRIES
    playing = True
    over = False
    best = getData(BEST_KEY, 0)

    # ------------------------------------------------------- the screen

    add([text("SERVER " + str(number), size=28), pos(20, 20), color(GREEN)])
    best_label = add([text("best " + str(best), size=18), pos(20, 52),
                      color(DIM)])
    tries_label = add([text("TRIES LEFT " + str(TRIES), size=24),
                       pos(780, 24), anchor("topright"), color(GREEN)])

    add([text("words.txt", size=20), pos(40, 100), color(DIM)])
    add([text("log.txt", size=20), pos(330, 100), color(DIM)])

    # The words, read from words.txt. Each one is something to click:
    # area() lets it know when the mouse is over it, and the dictionary
    # gives it two values of its own -- the word, and whether it has been
    # tried yet.
    y = 130
    for word in words:
        add([text(word, size=26), pos(50, y), color(GREEN), area(),
             {"word": word, "tried": False}, "word"])
        y = y + 32

    message = add([text("", size=36), pos(560, 450), anchor("center"),
                   color(GREEN), z(10)])
    under = add([text("", size=22), pos(560, 495), anchor("center"),
                 color(GREEN), z(10)])

    # ------------------------------------------------------------ the log
    #
    # Read log.txt and put every line of it on the screen. The old lines
    # are taken away first, so each guess is shown once.

    def show_log():
        destroyAll("entry")
        y = 130
        for guess in read_log():
            # "crash,2" becomes ["crash", "2"]: split(",") cuts it up at
            # the comma.
            parts = guess.split(",")
            add([text("> " + parts[0], size=24), pos(340, y), color(GREEN),
                 "entry"])
            add([text(parts[1] + "/" + str(length) + " right", size=24),
                 pos(500, y), color(DIM), "entry"])
            y = y + 32

    # ------------------------------------------------------------ guessing

    # A word lights up while the mouse is over it, unless it has been tried.
    @onUpdate("word")
    def light_up(row):
        if row.tried:
            row.color = DIM
        elif row.isHovering() and playing:
            row.color = BRIGHT
        else:
            row.color = GREEN

    # onClick runs on every click, wherever it is, and a tap on a phone
    # counts as a click. isHovering() asks whether the mouse is over a word,
    # which needs area() on it.
    @onClick
    def click():
        if not playing:
            return
        for row in get("word"):
            if row.isHovering() and not row.tried:
                guess(row)
                return

    def guess(row):
        global tries_left

        row.tried = True
        password = read_password()
        right = letters_right(row.word, password)

        # The guess goes on the end of the log. "a" means add: it keeps
        # what is there and writes after it, where "w" would empty it.
        with open("log.txt", "a") as log_file:
            log_file.write(row.word + "," + str(right) + "\n")
        show_log()

        if row.word == password:
            granted(password)
            return

        tries_left = tries_left - 1
        tries_label.text = "TRIES LEFT " + str(tries_left)
        play("wrong")
        shake(4)
        if tries_left == 0:
            locked_out(password)

    # --------------------------------------------------- in, or locked out

    def granted(password):
        global playing

        playing = False
        play("granted")
        message.text = "ACCESS GRANTED"
        under.text = "the password was " + password
        wait(2, next_server)

    def next_server():
        go("server", number + 1)

    def locked_out(password):
        global playing

        playing = False
        play("locked")
        shake(12)

        # The servers you got into is one less than the one you were
        # locked out of.
        hacked = number - 1
        if hacked > best:
            setData(BEST_KEY, hacked)
            best_label.text = "best " + str(hacked)

        message.text = "LOCKED OUT"
        message.color = RED
        under.text = "it was " + password + " -- servers hacked: " + str(hacked)

        # A second before you can start again, so a click meant for a word
        # does not skip past what happened.
        wait(1, ready_again)

    def ready_again():
        global over

        over = True
        add([text("press SPACE or click to start again", size=22),
             pos(560, 540), anchor("center"), color(GREEN)])

    def again():
        if over:
            go("server", 1)

    onKeyPress("space", again)
    onClick(again)

    # The first time, how to play comes up first.
    if not told:
        told = True
        say("One of these words is the server's password, and you have "
            "four tries. Click a word to guess it. The log tells you how "
            "many of its letters are in the right place -- the password "
            "has to match every line of it. There is no clock.")


# ------------------------------------------------------- 8. start the game

go("server", 1)

Make it yours

Open the whole game in the playground → It goes straight into the editor, ready to change and run.

  • Your own words. Put your own in WORDS. Words that look alike make the best game: pocket, rocket, socket and locket are all one letter apart.
  • Easier, or harder. TRIES = 6 to start with. TRIES = 3 once you're good at it.
  • A record of every game. In granted, open "history.txt" with "a" and write the password on the end. Nothing ever empties it, so it grows by one line every server you get into. Read it back and show how many lines it has.
  • A hint file. Write a hints.txt with a line for each word, like brick,a building block, read it back with split(","), and show the hint for the password at the start.