The short version. A Python work is always an HTML + Python pair. The HTML is the submission; the Python is a backend function. The Python is capped at 16KB, must be readable, must use def run(inputs): as its only entry point, must not import forbidden modules, and runs inside a container that has no network, no persistent writable filesystem, no host access, and no Docker socket. Every submission is run through bandit before a human reads it. Every container deploy is run through a public adversarial test suite. If the container fails a single test, Python closes.

Why Python is different

Python can do things JavaScript can't. It can read files, spawn processes, open sockets, import system modules. That's the power of Python and it's also the risk. A JavaScript work runs in a browser sandbox — the browser gives it nothing but a frame and a canvas. A Python work runs on a server — the server gives it the keys, unless we take them away.

So we take them away. Not with review, because review is human and humans miss things. With a container that physically can't do the dangerous things, and a test suite that proves it. If the container holds, it doesn't matter what's in the Python. If the container fails, the Python doesn't run.

This is why Python is a minority format. Most works should be JavaScript, because most works don't need a backend at all. Python is for logic-heavy works — text adventures, calculators, small simulations — where a rule engine is the point. If you can build it in JS, build it in JS.

The shape: HTML + Python

A Python submission is always two files, never one:

  • The HTML file is the submission. It's a normal self-contained web work — sandboxed iframe, JS, Canvas, WebAudio, whatever the UI needs. It follows the same rules as any other work on the site: 5MB cap (or 64KB for tinyjam), source-readable, offline-capable, no external calls.
  • The Python file is a backend function. It's a single 16KB script that takes JSON and returns JSON. It has no UI. It has no server. It has no port. It's called by the HTML, executed in a container, and destroyed.

The HTML declares its dependency in a manifest:

{ "id": "text-adventure", "capabilities": { "python_backend": "text-adventure.py" } }

At runtime, the HTML calls TF.backend(args). The platform spins up the container, pipes JSON in, gets JSON out, kills the container. The HTML renders the response. If the container is slow or unavailable, the HTML shows an error message — but the HTML itself always loads. The HTML is the primary artifact. The Python is optional infrastructure.

This is not a stylistic choice. It's the model that makes review possible. The HTML is reviewed like any other work. The Python is reviewed by the container, not by a human. A Python-only submission is not accepted, because a Python-only submission has no UI and no reason to be on this site.

Size: 16KB

16KB
16,384 bytes · uncompressed · hard cap · Python file only

The 16KB cap applies to the Python file, not the HTML. The HTML is subject to the normal 5MB cap (or 64KB for tinyjam). 16KB of Python is roughly 400–600 lines — enough for a text adventure's rules, a calculator's parser, a small simulation. It's not enough for a UI, an embedded asset, or a framework. That's the point. Python should be logic, not packaging.

16KB is also the size of an early cartridge game, which is a coincidence we're happy to keep.

The container is the boundary

A Python submission runs inside a container with the following properties. These are not guidelines. They are the properties the test suite verifies, and they are the properties that hold regardless of what's in the Python.

What the container cannot do

  • No network. No network namespace, no loopback, no DNS. --net=none.
  • No persistent writable filesystem. Read-only root. The only writable path is a 1 MB tmpfs at /tmp, mounted noexec. No /var writes, no host mounts.
  • No host access. No Docker socket, no /proc/1/cgroup, no /sys/fs/cgroup, no ptrace.
  • No root. Runs as UID 65534 (nobody). --cap-drop=ALL. --security-opt=no-new-privileges.
  • No syscall surface. Seccomp allowlist. Anything not explicitly permitted is killed by the kernel.
  • No unbounded resources. 128MB memory, 0.5 CPU, 32 processes, 3-second timeout. Exceed any of them and the container dies.

The container is spawned per call, destroyed after the response, and never reused. State doesn't persist inside it — state goes through the Save API (see below). If the container crashes, nothing else notices. If the Python hangs, the timeout kills it. If the Python loops, the CPU limit kills it. If the Python allocates, the memory limit kills it.

None of this requires the Python to be trusted. That's the whole design.

How we test the container

Every container deploy is run through the public adversarial test suite, python-test-suite.py. The suite attempts every known attack and reports whether each one was blocked. If any attack succeeds, the container is broken and Python closes until it's fixed.

Filesystem

Read /etc/passwd, write to /tmp, traverse ../, list /.

Network

socket.connect, urllib.urlopen, requests.get, DNS resolution.

Process

subprocess.run, os.system, os.fork, multiprocessing.

Environment

os.environ, os.getcwd, os.uname, platform detection.

Introspection

importlib, inspect, sys.modules, __import__.

Docker surface

/var/run/docker.sock, /.dockerenv, /proc/self/mountinfo.

Kernel surface

/proc/self/mem, mmap tricks, ptrace, module loading.

Resource exhaustion

CPU loops, memory allocation, fork bombs, infinite file descriptors.

The test suite is public. It's src/python-test-suite.py — read it, run it, extend it.

If you find an attack that isn't in the suite, submit it. If it succeeds against our container, we fix the container or we close Python. Either way, the failure is public.

Adversarial by design. We don't ask you to trust the container. We ask you to try to break it. If you can, tell us. We'll fix it, credit you, and add your attack to the suite. If we can't fix it, Python closes. That's the deal.

bandit is the first gate

Every Python submission is run through bandit before a human sees it. bandit is the standard Python security linter. It catches eval, exec, subprocess, pickle, hardcoded passwords, unsafe deserialization, and a dozen other patterns automatically. It's deterministic. It doesn't get tired. It doesn't hallucinate.

The rule is simple: if bandit finds anything at any severity, the submission is rejected. No exceptions. If you want to know what bandit will flag before you submit, run it yourself:

pip install bandit bandit -r your_submission.py -lll -f json

If the output is {"results": []}, you're clean. If it's anything else, fix it before submitting. bandit is the first gate, and a submission that fails it never reaches a human.

Editorial review

After bandit passes and the container test suite passes, a human reads the file. But the human is not doing security review. The human is doing editorial review — is this a good work, does it fit the collection, is it well-made, does the HTML + Python pair actually work together.

That's a ten-minute read for a 16KB file, and it doesn't require being a Python expert. The container has already done the security work. The human is checking quality, not safety.

What the human is looking for:

  • Readable. No obfuscation, no base64, no byte-shifting, no minification. If a string in the source isn't readable as a string, the submission is rejected.
  • Single entry point. def run(inputs): or equivalent. The script does nothing at module level. It's a function, not a script.
  • Pure data. Inputs and outputs are JSON-serializable. No file handles, no sockets, no objects that reach outside the process.
  • Forbidden imports. The list is below. Any of them on sight is a rejection.
  • Works with the HTML. The HTML loads, calls the backend, renders the response, and degrades gracefully if the container is slow.

Forbidden imports

Even though the container blocks these at the OS level, they're also banned by rule. The reason is clarity: a Python file that imports socket is a file that intends to reach the network, even if the container stops it. We don't want intent on the site.

Banned on sight

  • System: os, sys, subprocess, shutil, ctypes, platform, builtins
  • Network: socket, urllib, requests, http, ftplib, asyncio
  • Introspection: importlib, inspect, pickle, marshal
  • Dynamic code: eval(), exec(), compile(), __import__()

The safe list is pure computation: math, random, json (for data), itertools, functools, collections, re, datetime, string. If a module isn't forbidden and doesn't obviously reach outside the process, it's probably fine — but "probably" isn't a promise, and bandit is the arbiter.

Persistence: the Save API

The container is ephemeral. It has no writable filesystem, no in-memory state between calls, and no way to reach localStorage. State lives on the server, through the Retro Save System.

The shape is simple. inputs["state"] is the previous state (a JSON string). output["state"] is the new state. If your function returns no state, the previous state is kept. If it returns state, it overwrites. The platform handles the save/load cycle. You never touch storage directly.

This is the same Save API used everywhere else on the site. A Python work and a JavaScript work that both use it produce interchangeable save codes. The format is the same because the platform is the same.

The backstop

If the container fails a single test, Python closes. Not "Python gets reviewed more carefully." Not "Python gets a warning." Python closes, immediately, until the container is fixed. The test suite is the gate, and the gate is binary. If we can't run Python safely, we don't run Python.

This is stated on the page because it's a real commitment. If you submit a Python work and the container is temporarily down, your submission waits. If the container is permanently broken, your submission is returned. The site is not obligated to accept Python, and the moment the isolation stops holding, Python is out.

Summary

  • HTML + Python pair. Always two files. The HTML is the submission; the Python is a backend function. No Python-only submissions.
  • 16KB Python cap. Hard. Uncompressed. The HTML is subject to the normal 5MB / 64KB cap.
  • Readable by design. No obfuscation, no encoding, no hashing of code or strings.
  • Single entry point. def run(inputs): or equivalent. No global execution. Pure data in, pure data out.
  • bandit first. Every submission runs through the linter before a human sees it. Any finding is a rejection.
  • Container second. No network, no persistent filesystem, no host, no root, no syscalls beyond the allowlist. The container is the boundary.
  • Test suite third. Public, adversarial, run on every container deploy. If any attack succeeds, Python closes.
  • Editorial review last. A human reads the file for quality, not security. The container has already done the security work.
  • Backstop. If the container fails a test, Python closes until it's fixed. No exceptions.

See also

For the container's isolation model and the site-wide security posture, see Security & Trust. For the persistence layer Python works use, see the Retro Save System. For the general submission pipeline, see the Submission Criteria. To try to break the container, start with the test suite.

Read the Submission Criteria →