# Godot jam starter that tests itself (free sample of the Game Jam Kit)

Version 1.0, 2026-10-28. By PaperChaseWebb. Works with Grok Bot, Claude Code, Codex and Hermes. Free. One Python 3 script, standard library only. The game it writes was run on Godot 4.7.2, headless and with a window.

This is the part of the Game Jam Kit (pp-game-jam-kit, $29) you can use tonight: one command writes a small playable Godot 4 game, a 48-hour plan with seven milestones, the agent rules and three empty ledgers. The game can play itself headless and tell your agent whether it still works. The full kit adds the Unity two-day PRD, the three owner approval steps with hashes, the scope-cut checklist, a fifteen-check read-only jam_check.py tested on a seeded fixture, a tested LLM proxy for games and twelve prompts.

## Install in one line

Save this file, then in the same folder:

```
awk '/^```python$/{f=1;next}/^```$/{f=0}f' playable-godot-jam-starter-that-tests-itself-free-sample.md > jam_starter.py && python3 jam_starter.py my-jam
```

Or tell your agent: "Save the python block in this file as jam_starter.py and run python3 jam_starter.py my-jam." Then:

```
godot --path my-jam                              # play: survive 30 seconds, arrows or WASD, R to restart
godot --headless --path my-jam -- --selftest     # the game plays itself and exits 0 or 1
python3 jam_starter.py check my-jam              # five quick checks on the plan and files
```

You need Python 3.8 or newer and Godot 4.3 or newer. We tested on Godot 4.7.2. If godot is not on your path, use the full path to the binary, or open the folder in the Godot editor and press F5.

## What you get in my-jam/

| File | What it is |
|---|---|
| project.godot, main.tscn, main.gd | a dodge game: the goal line and a timer bar are on screen from the first frame, rocks fall, you survive 30 seconds or get hit, R restarts. Shapes only, so there is nothing to license yet |
| JAM.md | the 48-hour plan from the next full hour: theme, times, objective, what a player sees in the first ten seconds, seven milestones each with a v1, a first test and what to leave out, and a cut list |
| AGENTS.md, CLAUDE.md | ten rules for any coding agent; CLAUDE.md points Claude Code to AGENTS.md, and Codex, Grok Bot and Hermes can read AGENTS.md as is |
| LICENSES.csv, approvals.csv, builds.csv | empty ledgers: one row per asset, per owner yes and per tested build |

## The self-test

`--selftest` runs the game's own rules with no window: a player who never moves must lose, the timer must be able to reach the goal, and restart must reset. On Godot 4.7.2 it printed:

```
SELFTEST PASS: idle player lost at 4.6s, win reached at 30.0s, restart resets
```

We also set the goal to 999 seconds to make sure it can fail; it printed `SELFTEST FAIL: the timer should reach the goal, state was play` and exited 1. When the agent changes the game, it adds a line to `selftest()` for each new rule, and reports the exit code from the command.

## The quick check, worked example

`python3 jam_starter.py check FOLDER` reads and writes nothing. Exit 0 is clean, 1 is findings, 2 is no JAM.md.

| Code | Finding |
|---|---|
| S1 | a JAM.md field is empty or still a placeholder |
| S2 | a milestone has no v1, first test or leave out |
| S3 | an image, sound, model or font not in LICENSES.csv |
| S4 | an API key, or a direct call to an LLM vendor, in the project |
| S5 | Godot 3 syntax in a .gd file |

On a fresh folder it reports S1 twice, for the theme and owner placeholders, by design: fill them in and it prints 0. We then planted five faults in a copy: an empty controls line, "-" as M1's leave-out, a hit.wav with no license row, a made-up key in main.gd and an `onready var` line. Output:

```
S1 JAM.md field controls is empty or still a placeholder
S2 M1 has no leave out
S4 main.gd has an API key or calls an LLM vendor directly
S5 main.gd has Godot 3 syntax
S3 assets/hit.wav is not in LICENSES.csv
5 finding(s)
```

After the fixes it printed `0 finding(s)` and exited 0. A missing folder exits 2. Run on 2026-10-28.

## Can a published game call an LLM at runtime?

Yes, with three conditions:

1. **The key never goes into the game.** Anything in an export can be pulled out. The game calls a small server you control, and that server holds the key. The quick check flags a key or a vendor URL in the project (S4).
2. **The rules stay in code.** The model writes flavor, like one line of NPC dialogue. Code decides damage, prices, inventory and who wins, so nobody can talk the model into a free win.
3. **The spend has a hard cap.** Per player per day and in total per day, enforced on the server. Past a cap, the game shows a fixed line and keeps working.

Disclose it too: itch.io's upload form asks about generative AI by kind, and Steam's Content Survey asks you to describe guardrails for AI content generated while people play. The full kit has a tested standard-library proxy with both caps and a Godot client that falls back to a fixed line when the server is down.

## The ten rules (also written into AGENTS.md)

```
# AGENTS.md: jam rules for any coding agent (Grok Bot, Claude Code, Codex, Hermes)

1. Read JAM.md first. Work on one milestone at a time, the lowest one not marked done.
2. Do not change the theme, objective, milestones or cut list. If one looks wrong, stop and ask the owner.
3. Godot 4 only: CharacterBody2D, await, @export, @onready, instantiate(). No Godot 3 syntax.
4. After every change, run: godot --headless --path . -- --selftest  and report the exit code from the command, not from memory.
5. A milestone is done only when the web export opens and a person has played it. Say what you tested.
6. No asset file goes into the project without a row in LICENSES.csv (file, source, license, author, url, kind, ai_generated).
7. No new features after the freeze time in JAM.md. After it: bug fixes and cuts only.
8. Never put an API key in the project. If the game talks to an LLM, it calls the owner's own endpoint, and game rules stay in code.
9. Never upload, publish or submit. Prepare the build and the page text, then wait for the owner's yes.
10. Overnight or unattended runs work on their own branch. The owner plays it in the morning before anything merges.
```

## jam_starter.py

```python
#!/usr/bin/env python3
"""jam_starter.py: a playable Godot 4 jam starter, a 48-hour plan and agent rules in one folder.

Usage: python3 jam_starter.py NEW_FOLDER          write the starter (refuses to overwrite)
       python3 jam_starter.py check FOLDER        five quick read-only checks; exit 0 clean, 1 findings, 2 missing
Then:  godot --path NEW_FOLDER                    play it (Godot 4.3 or newer; tested on 4.7.2)
       godot --headless --path NEW_FOLDER -- --selftest   the game plays itself and prints PASS or FAIL
Standard library only, Python 3.8+. Free sample of the Game Jam Kit (pp-game-jam-kit).
PaperChaseWebb, version 1.0, 2026-10-28.
"""
import csv, os, re, sys
from datetime import datetime, timedelta

MAIN_GD = r"""extends Node2D
## Dodge starter: survive 30 seconds. Arrows or WASD to move, R to restart.
## Headless self-test: godot --headless --path . -- --selftest

const SIZE := Vector2(960, 540)
const GOAL := 30.0
const GOAL_TEXT := "Survive 30 seconds. Arrows or WASD to move."

var player := Vector2(480, 470)
var rocks: Array = []
var t := 0.0
var spawn := 0.5
var state := "play"
var rng := RandomNumberGenerator.new()


func _ready() -> void:
	restart()
	if "--selftest" in OS.get_cmdline_user_args():
		get_tree().quit(selftest())


func restart() -> void:
	player = Vector2(480, 470)
	rocks.clear()
	t = 0.0
	spawn = 0.5
	state = "play"
	rng.seed = 7


func read_input() -> Vector2:
	var x := float(Input.is_key_pressed(KEY_RIGHT) or Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_LEFT) or Input.is_key_pressed(KEY_A))
	var y := float(Input.is_key_pressed(KEY_DOWN) or Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_UP) or Input.is_key_pressed(KEY_W))
	return Vector2(x, y)


func step(delta: float, dir: Vector2, hits := true) -> void:
	if state != "play":
		return
	t += delta
	player = (player + dir.normalized() * 320.0 * delta).clamp(Vector2(16, 16), SIZE - Vector2(16, 16))
	spawn -= delta
	if spawn <= 0.0:
		spawn = maxf(0.15, 0.6 - t * 0.015)
		var x := rng.randf_range(20.0, SIZE.x - 20.0)
		if rng.randf() < 0.35:
			x = player.x
		rocks.append({"p": Vector2(x, -20.0), "v": rng.randf_range(200.0, 280.0) + t * 6.0, "r": rng.randf_range(12.0, 22.0)})
	for r in rocks:
		r["p"] = r["p"] + Vector2(0.0, r["v"] * delta)
	rocks = rocks.filter(func(r): return r["p"].y < SIZE.y + 40.0)
	if hits:
		for r in rocks:
			if r["p"].distance_to(player) < r["r"] + 14.0:
				state = "lost"
				return
	if t >= GOAL:
		state = "won"


func _process(delta: float) -> void:
	if state != "play" and Input.is_key_pressed(KEY_R):
		restart()
	step(delta, read_input())
	queue_redraw()


func _draw() -> void:
	var font := ThemeDB.fallback_font
	draw_rect(Rect2(Vector2.ZERO, SIZE), Color("#0a0a0a"))
	for r in rocks:
		draw_circle(r["p"], r["r"], Color("#c0392b"))
	draw_circle(player, 14.0, Color("#E8A23C"))
	draw_rect(Rect2(0, 0, SIZE.x * minf(t / GOAL, 1.0), 6), Color("#E8A23C"))
	draw_string(font, Vector2(16, 34), GOAL_TEXT, HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color.WHITE)
	draw_string(font, Vector2(16, 62), "%.1f / %d s" % [t, int(GOAL)], HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color("#bbbbbb"))
	if state != "play":
		var msg := "You made it. R to play again." if state == "won" else "Hit. R to try again."
		draw_string(font, Vector2(300, 280), msg, HORIZONTAL_ALIGNMENT_LEFT, -1, 30, Color("#E8A23C"))


func selftest() -> int:
	var fails := []
	if GOAL_TEXT.length() < 10:
		fails.append("goal text missing")
	restart()
	var frames := 0
	while state == "play" and frames < 60 * 40:
		step(1.0 / 60.0, Vector2.ZERO)
		frames += 1
	if state != "lost":
		fails.append("a player who never moves should lose, state was " + state)
	var lost_at := t
	restart()
	frames = 0
	while state == "play" and frames < 60 * 40:
		step(1.0 / 60.0, Vector2(1, 0) if (frames / 90) % 2 == 0 else Vector2(-1, 0), false)
		frames += 1
	if state != "won":
		fails.append("the timer should reach the goal, state was " + state)
	restart()
	if state != "play" or t != 0.0 or rocks.size() != 0:
		fails.append("restart did not reset")
	if fails.is_empty():
		print("SELFTEST PASS: idle player lost at %.1fs, win reached at %.1fs, restart resets" % [lost_at, GOAL])
		return 0
	for f in fails:
		print("SELFTEST FAIL: ", f)
	return 1
"""

PROJECT = r"""config_version=5

[application]

config/name="My Jam"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")

[display]

window/size/viewport_width=960
window/size/viewport_height=540

[rendering]

renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
"""

SCENE = r"""[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://main.gd" id="1"]

[node name="Main" type="Node2D"]
script = ExtResource("1")
"""

PLAN = [  # hours after start, goal, v1, first test, leave out
    (6, "Core loop playable", "one verb, one threat, a lose state; web export opens", "selftest passes and one person plays for 60 seconds", "menus, art, sound, save"),
    (12, "Win, lose, restart", "goal on screen in the first 10 seconds; R restarts", "a new player says the goal back after 10 seconds, no help", "levels, upgrades"),
    (20, "The theme twist", "one mechanic that only makes sense with the theme", "two playtesters; note where each one stops", "a second twist"),
    (28, "Art and sound pass", "shapes swapped for licensed or own art, three sounds, every file in LICENSES.csv", "check S3 clean, then the owner says yes to the license list", "animation polish, more than one music loop"),
    (36, "Difficulty and feel, then freeze", "easy to hard in about three minutes", "a new player's first run lasts 30 to 90 seconds", "any new feature after this row"),
    (44, "Bugs and cuts", "no crash in ten runs of the web build; cut list applied", "export and play ten runs in the browser", "anything that is not a bug fix"),
    (48, "Page and submit", "page with how to play, controls, AI disclosure and credits", "open your own upload in a private window and play it; owner yes before upload", "trailers, devlogs"),
]

CUTS = ["Menus beyond a title line and a restart key", "Save and load", "A second level or area", "Anything online, including leaderboards", "A settings screen"]

AGENTS = """# AGENTS.md: jam rules for any coding agent (Grok Bot, Claude Code, Codex, Hermes)

1. Read JAM.md first. Work on one milestone at a time, the lowest one not marked done.
2. Do not change the theme, objective, milestones or cut list. If one looks wrong, stop and ask the owner.
3. Godot 4 only: CharacterBody2D, await, @export, @onready, instantiate(). No Godot 3 syntax.
4. After every change, run: godot --headless --path . -- --selftest  and report the exit code from the command, not from memory.
5. A milestone is done only when the web export opens and a person has played it. Say what you tested.
6. No asset file goes into the project without a row in LICENSES.csv (file, source, license, author, url, kind, ai_generated).
7. No new features after the freeze time in JAM.md. After it: bug fixes and cuts only.
8. Never put an API key in the project. If the game talks to an LLM, it calls the owner's own endpoint, and game rules stay in code.
9. Never upload, publish or submit. Prepare the build and the page text, then wait for the owner's yes.
10. Overnight or unattended runs work on their own branch. The owner plays it in the morning before anything merges.
"""


def write(folder):
    if os.path.exists(folder):
        print("ERROR: %s already exists; pick a new folder name" % folder); sys.exit(2)
    os.makedirs(os.path.join(folder, "assets"))
    start = datetime.now().astimezone().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
    at = lambda h: (start + timedelta(hours=h)).isoformat()
    name = os.path.basename(os.path.abspath(folder))
    jam = ["# JAM.md", "engine: godot 4.7", "theme: (write the jam theme here)", "start: " + at(0), "deadline: " + at(48),
           "freeze: " + at(36), "owner: (your name)", "objective: survive 30 seconds",
           "first_10s: the goal line and the timer bar are on screen before the first rock falls",
           "controls: arrows or WASD to move, R to restart", "ai_disclosure: code written with a coding agent; no AI art or audio yet",
           "llm_runtime: none", "", "## Milestones", "",
           "| id | due | goal | v1 | first test | leave out | status |", "|---|---|---|---|---|---|---|"]
    jam += ["| M%d | %s | %s | %s | %s | %s | todo |" % (i + 1, at(h), g, v, t, l) for i, (h, g, v, t, l) in enumerate(PLAN)]
    jam += ["", "## Cut list", ""] + ["- " + c for c in CUTS] + [""]
    files = {
        "project.godot": PROJECT.replace('"My Jam"', '"%s"' % name.replace('"', "")),
        "main.tscn": SCENE, "main.gd": MAIN_GD, "JAM.md": "\n".join(jam), "AGENTS.md": AGENTS,
        "CLAUDE.md": "Read AGENTS.md and JAM.md before any change. They are the rules for this project.\n",
        "LICENSES.csv": "file,source,license,author,url,kind,ai_generated\n",
        "approvals.csv": "at,kind,ref,words\n", "builds.csv": "at,milestone,action,build,exit,tested,note\n",
        ".gitignore": ".godot/\nbuild/\n",
    }
    for rel, text in files.items():
        with open(os.path.join(folder, rel), "w", encoding="utf-8", newline="\n") as f: f.write(text)
    print("wrote %s: %d files. Next:" % (folder, len(files)))
    print("  godot --path %s                          play it" % folder)
    print("  godot --headless --path %s -- --selftest  the game plays itself" % folder)
    print("  python3 jam_starter.py check %s           quick plan check" % folder)


def check(folder):
    if not os.path.isfile(os.path.join(folder, "JAM.md")):
        print("ERROR: no JAM.md in", folder); sys.exit(2)
    text = open(os.path.join(folder, "JAM.md"), encoding="utf-8").read()
    out = []
    head = dict(re.findall(r"(?m)^([a-z_0-9]+):[ \t]*(.*)$", text.split("## ")[0]))
    for k in ("engine", "theme", "start", "deadline", "freeze", "owner", "objective", "first_10s", "controls", "ai_disclosure"):
        if not head.get(k, "").strip() or head[k].startswith("("):
            out.append("S1 JAM.md field %s is empty or still a placeholder" % k)
    for row in re.findall(r"(?m)^\| (M\d+) \|(.*)$", text):
        cells = [c.strip() for c in row[1].split("|")]
        for i, n in ((2, "v1"), (3, "first test"), (4, "leave out")):
            if len(cells) <= i or not cells[i] or cells[i] in ("-", "tbd"):
                out.append("S2 %s has no %s" % (row[0], n))
    with open(os.path.join(folder, "LICENSES.csv"), newline="", encoding="utf-8") if os.path.isfile(os.path.join(folder, "LICENSES.csv")) else open(os.devnull) as f:
        listed = {r.get("file", "") for r in csv.DictReader(f)}
    for d, dirs, fs in os.walk(folder):
        dirs[:] = [x for x in dirs if x not in (".godot", "build", ".git")]
        for n in fs:
            rel = os.path.relpath(os.path.join(d, n), folder).replace("\\", "/")
            if rel.lower().endswith((".png", ".jpg", ".webp", ".ogg", ".wav", ".mp3", ".glb", ".ttf", ".otf")) and rel not in listed:
                out.append("S3 %s is not in LICENSES.csv" % rel)
            if rel.endswith((".gd", ".tscn", ".tres", ".cfg", ".godot", ".json", ".js", ".html")):
                t = open(os.path.join(d, n), encoding="utf-8", errors="ignore").read()
                if re.search(r"sk-[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{16,}|AIza[0-9A-Za-z_-]{20,}|api\.openai\.com|api\.anthropic\.com|api\.x\.ai", t):
                    out.append("S4 %s has an API key or calls an LLM vendor directly" % rel)
                if rel.endswith(".gd") and re.search(r"\bKinematicBody2?D?\b|\byield\s*\(|^\s*export\s+var\b|^\s*onready\s+var\b|\.instance\(\)", t, re.M):
                    out.append("S5 %s has Godot 3 syntax" % rel)
    for line in out: print(line)
    print("%d finding(s)" % len(out))
    sys.exit(1 if out else 0)


if __name__ == "__main__":
    a = sys.argv[1:]
    if len(a) == 2 and a[0] == "check": check(a[1])
    elif len(a) == 1 and a[0] not in ("-h", "--help", "check"): write(a[0])
    else: print(__doc__); sys.exit(2)
```

## What the full kit adds

Game Jam Kit (pp-game-jam-kit, $29): the Unity 6 two-day PRD with seven milestones, the three owner approval steps (scope lock, license check, upload) tied to hashes, the scope-cut checklist, overnight run rules, jam_check.py with fifteen read-only checks tested on a seeded fixture with fifteen planted faults (and a Unity fixture with four), llm_proxy.py and llm_client.gd tested together, rules blocks for Godot, Unity and a playtester, twelve prompts and setup for four harnesses.

Related: Game Dev with AI Agents (pp-game-dev-agents), Engine Agent Kits (pp-engine-agent-kits), Two-Agent Build and Test Loop (pp-two-agent-build-test-loop).

PaperChaseWebb 💸
