A single-chapter 3D vertical slice in Godot 4.7. One room, one evening, one
decision. See spec.md for the full pitch and art direction.
- Godot 4.7 (Forward+ rendering method)
- GDScript only - no C# or native modules
Open the project in the Godot 4.7 editor and press Play, or from the repo root:
godot --path .The project boots to the title screen at 1920x1080. Start loads the chapter entry scene; Quit exits cleanly.
| Path | Purpose |
|---|---|
scenes/room/ |
The Ziggy's interior and its assembly scenes (ziggys_room.tscn is the shipped room) |
scenes/room/ziggys_shell.tscn |
CSG-authored room shell — editable source, not instanced by the shipped room |
scenes/room/ziggys_shell_baked.tscn |
Baked MeshInstance3D shell instanced by ziggys_room.tscn |
scenes/props/ |
One CSG-authored scene per prop or furniture piece — editable source |
scenes/props/baked/ |
Baked MeshInstance3D version of every prop, instanced by ziggys_room.tscn |
scenes/characters/ |
Meckies and the people of the bar (npc_human.tscn is CSG source, npc_human_baked.tscn is what the room ships) |
scenes/ui/ |
Title, pause, settings, dialogue UI |
tools/bake_csg.gd |
Headless bake tool — CSG source scenes in, baked MeshInstance3D scenes out |
scripts/autoload/ |
Singletons registered in project settings (GameState, SettingsManager, DialogueDB) |
scripts/systems/ |
Gameplay systems (interaction, brownout, dialogue runner) |
scripts/ui/ |
Scripts backing the UI scenes |
content/dialogue/ |
Data-driven dialogue as JSON, validated by schema |
content/chapters/ |
Data-driven chapters as JSON, validated by schema (chapter-zero.json is the shipped chapter) |
materials/ |
Shared material resources (.tres) |
shaders/ |
Godot shader files (.gdshader) |
tests/ |
Headless smoke tests |
Godot's docs are explicit that CSG (CSGShape3D and friends) is a
prototyping tool with a runtime cost — every CSG node rebuilds its own mesh
and collision shape, and doesn't batch or cull as well as a plain
MeshInstance3D. This project uses CSG as the authoring layer (fast to
block out, no imported/modelled assets, still procedurally generated) but
the shipped room ships baked geometry, not live CSG nodes:
-
Author here (edit these, then re-bake):
scenes/room/ziggys_shell.tscn, every scene directly underscenes/props/(notscenes/props/baked/), andscenes/characters/npc_human.tscn. These stay ordinaryCSGShape3D/CSGCombiner3Dtrees — open them in the editor, tweak primitives and materials, and preview them like any other scene. -
Ship this (never hand-edited):
scenes/room/ziggys_shell_baked.tscn, every scene underscenes/props/baked/, andscenes/characters/npc_human_baked.tscn.scenes/room/ziggys_room.tscn— the one scene actually instanced by the game — instances these, not the CSG source. Each is a plainMeshInstance3D(or a small tree of them) with baked-in per-surface materials, plus aStaticBody3D+CollisionShape3Dwherever the source haduse_collision = true. -
Re-bake after any edit to a source scene:
godot --headless --path . --script res://tools/bake_csg.gdThis instances every scene in
tools/bake_csg.gd'sTARGETSlist, lets the CSG tree settle, then for every independent CSG root callsCSGShape3D.bake_static_mesh()/bake_collision_shape()— the same operation the editor's CSG → Bake Mesh Instance menu action performs — and writes the result to the matching*_baked.tscnpath. Non-CSG children (lights,AudioStreamPlayer3Dgenerators, hand-authored collision, and — fornpc_human.tscn— theNpcHumanscript and its exported properties) are carried over untouched, so per-instance overrides set inziggys_room.tscn(npc_id,body_color,pose, …) keep working exactly as before against the baked scene. -
Emissive parts stay independently dimmable. A handful of CSG shapes per prop (pendant bulbs, the oven ember, the neon tubes, the jukebox's arch/strips) are tagged into the
warm_lightsgroup so Phase 11's brownout beat can fade them. Baking preserves that group tag on the resultingMeshInstance3D, andLightRegistry.scale_warm_lights()(scripts/lighting/light_registry.gd) reads/writes the emissive material viamaterial_override(or surface 0's material as a fallback) instead ofCSGShape3D's ownmaterialproperty, so the beat reads identically whether it's driving the CSG source or the baked scene. -
Why NPCs bake too:
npc_human.tscn's root is a plainNode3D(notCSGCombiner3D) carrying a hand-authored capsuleStaticBody3D/CollisionShape3Dinstead of CSG's automaticuse_collisionbody, specifically so the same script and the same bake pipeline used for props also works for the ten regulars — ten CSG figures at ~13 primitives each was the single biggest concentration of runtime CSG nodes in the room.
See PERFORMANCE.md for the frame-rate win this unlocks and the
shadow-caster audit that goes with it.
- Scenes are text-format
.tscnonly, kept small and composed - one scene per prop, assembled into the room scene - so diffs stay reviewable. - Resources are text-format
.tres. - Content lives in JSON under
content/, not in code; schemas validate it. - The
GameStateautoload holds chapter flags (selected_meckie,brownout_fired,closing_decision) and emits a signal when each changes. - Input actions defined in project settings:
move_forward,move_back,move_left,move_right(WASD and arrows),interact(E),pause(Escape), plus explicitui_accept/ui_cancel. - Audio buses:
MasterwithMusic,SFX, andAmbientrouted into it (default_bus_layout.tres).
Import and parse checks plus the Sprint 1 smoke test run headless:
godot --headless --path . --import
godot --headless --path . res://tests/smoke_test.tscnThe smoke test verifies GameState defaults and signals, the title screen's
buttons and wiring, and that Start lands in the chapter entry scene. Note
that --headless cannot render; anything that produces a picture must run
windowed.
Phase 3 added the room probes and Phase 4 the lighting probes:
godot --headless --path . res://tests/room_probe.tscn # structure, collision
godot --headless --path . res://tests/lighting_probe.tscn # warm_lights rig, env
godot --path . res://tests/room_render_probe.tscn # windowed screenshot
godot --path . res://tests/lighting_render_probe.tscn # windowed, checks that
# warm AND cyan pixels
# read in one frame,
# saves 3 QA anglesScreenshots land in tests/artifacts/ (gitignored).
Phase 8 added the dialogue content probe:
godot --headless --path . res://tests/dialogue_content_probe.tscnValidates every NPC's content file against the schema, prints a per-NPC
pass/fail table, and checks the cross-file rules (every NPC has a
pre_brownout and post_brownout line, no two NPCs share an identical
post_brownout line, Caroline's closing entry has exactly four choices).
Phase 16 added the bake-to-mesh probes:
godot --headless --path . res://tests/room_probe.tscn # now also asserts
# zero CSGShape3D
# nodes in the room
godot --headless --path . res://tests/fps_overlay_probe.tscn # F3 toggle + readout
godot --path . res://tests/perf_phase16_probe.tscn # windowed, 1920x1080,
# min/avg/max fps
# across a room tour
godot --path . res://tests/qa_phase16_probe.tscn # windowed, saves
# pre-brownout/
# mid-fade/full-
# brownout/dialogue/
# four-option shots
# to
# .turkey/screenshots/
# phase-16/The chapters-as-content work added the chapter validation probe and its own windowed gating capture:
godot --headless --path . res://tests/chapter_validation_probe.tscn # every
# content/chapters/*.json
# against the schema,
# ChapterDB's cast/kind
# checks, and (added
# for the second
# chapter) locked/
# unlocked reporting
# against a synthetic
# GameState/save
# fixture
godot --path . res://tests/qa_gating_probe.tscn # windowed, walks a
# fresh save through
# chapter select
# (Chapter Zero
# unlocked, the second
# chapter greyed),
# simulates a
# completed Chapter
# Zero to unlock it,
# enters its
# cast-filtered room,
# then re-drives
# Chapter Zero's own
# brownout/decision
# for a regression
# check against
# .turkey/screenshots/
# phase-5/'s baseline;
# saves to
# .turkey/screenshots/
# phase-6/Dialogue is data, not code: nothing in scripts/ holds a line a player will
read. This is the shape later chapters should reuse.
- Schema:
content/dialogue/dialogue.schema.json(JSON Schema, draft 2020-12) defines the shape every NPC file must match — a top-levelschema_version(currently1),npc_id,display_name, andentrieskeyed by chapter state:pre_brownout,post_brownout, and (Caroline only)closing. Each state has a non-emptylinesarray;closingadditionally requires achoicesarray of exactly four{id, text}objects — the four closing-decision answers. - Content: one file per NPC,
content/dialogue/<npc_id>.json,npc_idmatching the roster inscripts/data/npc_defs.gd. Every regular has at least apre_brownoutand a distinctpost_brownoutreaction line; Caroline's file additionally carries theclosingentry. - Validation:
scripts/systems/dialogue_schema_validator.gd(DialogueSchemaValidator) is a hand-rolled interpreter for the subset of JSON Schema this project's schema actually uses (type,required,properties/additionalProperties,items,minItems/maxItems,minLength,const, local$refs into$defs) — no addon. Errors name the offending path (e.g.chad.json.entries.pre_brownout.lines[0]: string is empty...) rather than just "invalid". - Access: the
DialogueDBautoload (scripts/autoload/dialogue_db.gd) loads and validates every NPC file on boot and exposesget_lines(npc_id, state)andget_closing_choices(). A file that's missing or fails validation is reported loudly viapush_errornaming the file and the specific violation — it never falls back to a placeholder string. - Editing: change a line in JSON and rerun;
DialogueDBpicks it up with no code change. Run the probe above to confirm content is still schema-valid before committing.
Later chapters that add dialogue should point their content at a schema
with the same shape (bump schema_version if the shape changes) rather
than inventing a new one.
A chapter is data too: which NPCs are in the room and what happens, in
order, is a JSON file — not a scene-specific director node wired up by
hand. content/chapters/chapter-zero.json is the first chapter authored
in this format, replacing the original hand-wired implementation rather
than living alongside it (see spec-chapters.md for the full rationale and
docs/SAVE_FORMAT.md for why its three save keys never moved).
- Schema:
content/chapters/chapter.schema.json(JSON Schema, draft 2020-12) defines the shape every chapter file must match — a top-levelschema_version(currently1),id,title,author,summary, an optionalrequiresarray (absent means "playable any time"), acastarray of NPC ids, and an orderedbeatsarray. Each beat has anid, akindfrom the closed setambience/lighting/dialogue/decision/end, and an optionalaftertrigger ({conversations: N}, optionallysince: <beat_id>) — absent means "fires on start." - Content: one file per chapter,
content/chapters/<id>.json. A chapter cannot introduce a new beat kind or room geometry — every beat it can ask for is a kind the engine already implements, so a chapter file is data, never a way to run arbitrary code. - Validation:
scripts/systems/chapter_schema_validator.gdreuses the same hand-rolled JSON Schema interpreter dialogue content uses, plusChapterDB.check_chapter_valid()'s business-logic checks beyond the schema — everycastid must be a real NPC with loaded dialogue content, and every beat'skindmust be one of the closed five. A chapter that fails either is reported loudly (naming the file and the exact violation) and excluded from the loaded set; one bad chapter file never blocks its siblings. - Access: the
ChapterDBautoload (scripts/autoload/chapter_db.gd) loads and validates every chapter file on boot.scripts/ui/chapter_select.gdlists every loaded chapter (Chapter Zero included — there is no chapter-specific row anymore), greying out one whoserequiresisn't met by the current save rather than hiding it. - Running:
scripts/systems/beat_runner.gd(BeatRunner) walks a loaded chapter'sbeatsin order, evaluating each one'saftertrigger against completed conversations, and dispatches eachkindto the engine's existing implementation of it —BrownoutDirectorforlighting: brownout,DialogueUIfordialogue/decision. Those nodes no longer decide when to fire (no self-registeredconversation_completedlisteners, no debug-key handling of their own); BeatRunner owns every trigger, and they're left as the callable fade/effect and prompt logic it invokes. requiresandwritesnamespacing: five different people can write a chapter for the same bar without negotiating an order first, because continuity is opt-in and self-contained.requires(optional, defaults to empty) is a list of{flag, equals}checks against the current save, evaluated viaSaveManager.current_flag_value()/ChapterDB.first_unmet_requirement()— an absentrequiresmeans "playable any time," which must stay the easy, common case. Adecisionbeat'swritesis the flag it records the player's answer under, and it must be namespaced<your-chapter-id>.<name>(e.g.ziggys.the-morning-after.decision) so two chapters can never collide on the same key.content/chapters/the-morning-after.jsonis the second worked example: itrequiresziggys_chapter_zero.closing_decision == "organize"(Chapter Zero's own closing flag — seedocs/SAVE_FORMAT.md), so it's greyed out in chapter select until a save has that specific decision recorded, and its owndecidebeat writesziggys.the-morning-after.decisionwithout ever touchingziggys_chapter_zero.*. A chapter that doesn't want continuity just omitsrequiresentirely, exactly likechapter-zero.jsonitself does.- Editing: change a chapter's JSON and rerun;
ChapterDBpicks it up with no code change.godot --headless --path . res://tests/chapter_validation_probe.tscnvalidates every file incontent/chapters/, including that any chapter with an unmetrequirescorrectly reports itself locked.
The two-temperature look lives in scenes/room/ziggys_room.tscn: warm amber
pendants, red-pink neon and orange oven glow inside, and the cold #00d4ff
data-center wash (DataCenterWash, a shadowed DirectionalLight3D) raking
through the front window as a long cyan rectangle on the floor. One
WorldEnvironment carries glow, volumetric fog, subtle SSAO, SSR and a warm
color-correction nudge. Every warm instrument sits in the warm_lights
group; scripts/lighting/light_registry.gd (LightRegistry) enumerates and
scales the set for the Phase 11 brownout.