Skip to content
PDFGame

Tutorial · 11 min read · Updated

Make Your Own PDF Game: A Guess-the-Number Game in Pure Python

We'll write a real, playable PDF game from scratch with nothing but Python's standard library — and learn exactly what's inside a PDF along the way.

The best way to understand PDF games is to make one. In this tutorial we'll build "Guess the Number": the PDF secretly picks a number from 1 to 100, you type guesses into a box, press a button, and the document tells you whether you're too high or too low. It's small enough to understand completely and uses every key ingredient of bigger games: a startup script, a text input, a clickable button and a field the script writes to.

We won't use any PDF library. PDF is a text-based format at heart, and writing the objects by hand is the clearest way to see how it works. All you need is Python 3 and a desktop Chromium browser for testing.

Step 1: Know the anatomy of a PDF

A PDF file is four things in a row:

  1. A header line, %PDF-1.7, followed by a comment with a few high bytes so tools treat the file as binary.
  2. A list of numbered objects: "1 0 obj … endobj". Objects are dictionaries (<< /Key value >>), arrays, strings, numbers or streams of data.
  3. A cross-reference table (xref) listing the byte offset where each object starts.
  4. A trailer that names the root object (the catalog) and a startxref line pointing at the xref table.

Objects reference each other with "N 0 R". The catalog points to the page tree, the page tree lists pages, and each page lists its annotations — which is where our form fields live.

Step 2: Plan the objects

Our game needs ten objects:

  • 1 — Catalog, with an /OpenAction that runs JavaScript when the file opens to pick the secret number.
  • 2 — Page tree.
  • 3 — The page, listing its three widget annotations.
  • 4 — The AcroForm dictionary: the list of fields, plus a default font and /NeedAppearances true so the viewer draws text fields for us.
  • 5 — The Helvetica font, one of the standard 14 fonts every viewer has built in.
  • 6 — The page's static content stream: the title and instructions.
  • 7 — A text field named guess where the player types.
  • 8 — A push button named go whose click action runs the game logic.
  • 9 — A read-only text field named msg that shows feedback.
  • 10 — The button's appearance: a small grey rectangle with the word Guess.

Step 3: Write the game logic

The startup script stores state on the global object, which persists between script runs in the same document:

jslisting
global.secret = Math.floor(Math.random() * 100) + 1;
global.tries = 0;

The button script reads the guess with this.getField("guess").value, compares it with the secret and writes a message into the msg field. Note that field values come back as strings, so we parse them, and we validate the range so typing "banana" gets a helpful reply instead of an error.

Step 4: The complete Python generator

Save this as make_guess.py and run python3 make_guess.py. It writes guess.pdf next to it. The comments map each object to the plan above.

python
# make_guess.py: writes guess.pdf, a "guess the number" game, using only the standard library.

def pdf_str(s):
    """Encode text as a PDF literal string, escaping \\, ( and )."""
    return "(" + s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + ")"

INIT_JS = """
global.secret = Math.floor(Math.random() * 100) + 1;
global.tries = 0;
"""

GUESS_JS = """
var g = parseInt(this.getField("guess").value, 10);
var out = this.getField("msg");
if (isNaN(g) || g < 1 || g > 100) {
  out.value = "Type a whole number from 1 to 100.";
} else {
  global.tries++;
  if (g < global.secret) out.value = g + " is too low.";
  else if (g > global.secret) out.value = g + " is too high.";
  else {
    out.value = "Got it in " + global.tries + " tries! I picked a new number.";
    global.secret = Math.floor(Math.random() * 100) + 1;
    global.tries = 0;
  }
}
this.getField("guess").value = "";
"""

page_text = b"BT /F1 28 Tf 72 700 Td (Guess the number) Tj ET\n" \
            b"BT /F1 12 Tf 72 670 Td (I'm thinking of a number from 1 to 100.) Tj ET\n"
button_ap = b"0.85 g 0 0 120 30 re f BT /F1 14 Tf 0 g 38 10 Td (Guess) Tj ET\n"

objects = [
    # 1: catalog: points to pages and the form, runs INIT_JS on open
    "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R "
    "/OpenAction << /S /JavaScript /JS " + pdf_str(INIT_JS) + " >> >>",
    # 2: page tree
    "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    # 3: the single US-Letter page with three widgets
    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
    "/Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R "
    "/Annots [7 0 R 8 0 R 9 0 R] >>",
    # 4: interactive form dictionary
    "<< /Fields [7 0 R 8 0 R 9 0 R] /NeedAppearances true "
    "/DA (/F1 14 Tf 0 g) /DR << /Font << /F1 5 0 R >> >> >>",
    # 5: a standard font every viewer has
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
    # 6: static page content (filled in below)
    None,
    # 7: text field the player types into
    "<< /Type /Annot /Subtype /Widget /FT /Tx /T (guess) /Rect [72 600 192 630] "
    "/DA (/F1 14 Tf 0 g) /MK << /BC [0 0 0] /BG [1 1 1] >> /F 4 /P 3 0 R >>",
    # 8: push button (Ff 65536) whose mouse-up action runs GUESS_JS
    "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [204 600 324 630] "
    "/MK << /CA (Guess) >> /AP << /N 10 0 R >> /F 4 /P 3 0 R "
    "/A << /S /JavaScript /JS " + pdf_str(GUESS_JS) + " >> >>",
    # 9: read-only (Ff 1) text field for feedback
    "<< /Type /Annot /Subtype /Widget /FT /Tx /Ff 1 /T (msg) /Rect [72 550 540 580] "
    "/DA (/F1 14 Tf 0 g) /V (Good luck.) /F 4 /P 3 0 R >>",
    # 10: button appearance (filled in below)
    None,
]

def stream(data, extra=""):
    return ("<< /Length %d %s>>\nstream\n" % (len(data), extra)).encode() + data + b"\nendstream"

out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = []
for num, obj in enumerate(objects, start=1):
    offsets.append(len(out))
    if num == 6:
        body = stream(page_text)
    elif num == 10:
        body = stream(button_ap, "/Type /XObject /Subtype /Form /BBox [0 0 120 30] "
                                 "/Resources << /Font << /F1 5 0 R >> >> ")
    else:
        body = obj.encode("latin-1")
    out += b"%d 0 obj\n" % num + body + b"\nendobj\n"

xref_at = len(out)
out += b"xref\n0 %d\n" % (len(objects) + 1)
out += b"0000000000 65535 f \n"
for off in offsets:
    out += b"%010d 00000 n \n" % off
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objects) + 1, xref_at)

with open("guess.pdf", "wb") as f:
    f.write(out)
print("wrote guess.pdf,", len(out), "bytes")
make_guess.py — standard library only

Step 5: Understand the xref table

The part people usually get wrong when hand-writing PDFs is the cross-reference table. Every entry is exactly 20 bytes: a 10-digit byte offset, a space, a 5-digit generation number, a space, n (in use) or f (free), and a two-character line ending. The first entry is always object 0, free, with generation 65535.

Our script records len(out) just before writing each object, which is precisely its byte offset, then writes the table and the startxref value. If you edit the file by hand afterwards, those offsets go stale. Chrome and most viewers will quietly repair a broken xref, which hides the bug until the file reaches a stricter tool — so always regenerate rather than hand-edit.

Step 6: Test it in Chrome

  1. Drag guess.pdf into a desktop Chrome, Edge or Brave window, or open it with File → Open.
  2. Click the empty box, type a number and press the Guess button.
  3. Read the message below. Keep guessing until you win — a new number is picked automatically.

If nothing happens, the most likely causes are: the file was opened in a viewer without JavaScript support (Preview, a phone, some Linux viewers); a parenthesis in a script wasn't escaped; or a field name in getField doesn't match the /T name exactly. Adding app.alert("reached here") at the top of a script is the PDF equivalent of console.log debugging.

Pitfalls and pro tips

  • Escape strings. Our pdf_str helper escapes backslashes and parentheses. Skip it and a single "(" in your JavaScript ends the string early.
  • Give buttons an appearance. With /NeedAppearances true viewers draw text fields, but push buttons may render invisibly without an /AP stream. We supply one.
  • Coordinates start at the bottom-left. y = 792 is the top of a US-Letter page.
  • Use app.setInterval for anything animated, and remember it takes a string of code, not a function.
  • Minimise redraws. Each change to a field's value or visibility makes the viewer regenerate its appearance; in an animated game, only update fields that changed.
  • Keyboard input only reaches focused text fields. For real-time controls, add a field with a /K keystroke action and read event.change.
  • Test in more than one viewer. PDFium is the most forgiving; PDF.js and Acrobat will catch different mistakes.

Where to go next

Once Guess the Number works, the jump to an arcade game is mostly about rendering. Make a grid of small fields named c_x_y, toggle their display property from a function called by app.setInterval, and read W/A/S/D from a keystroke field. That's the architecture of PDFtris and our own PDF Snake. Study their sources, then build something nobody has put in a PDF yet.

FAQ

Q1Do I need Adobe Acrobat to make a PDF game?

No. A PDF is a structured text format, so a short script in Python (or any language) can write a working interactive PDF. Acrobat is just one convenient editor.

Q2Why does my PDF game do nothing when I open it?

Usually the viewer doesn't run PDF JavaScript, a parenthesis in the script isn't escaped, or a getField name doesn't match. Test in desktop Chrome and add app.alert calls to trace execution.

Q3Can I use modern JavaScript syntax?

PDFium runs scripts in V8, so modern syntax generally works there. Acrobat and other viewers may be stricter — stick to ES5-style code if you want the widest compatibility.

Q4Can I submit my PDF game to PDFGame?

Yes — email it to [email protected] with a short description and a link to the source if it's public.

Keep reading