Castle Crawler - prototype

A minimal Godot 4.6 project that proves the risky parts of the design in one playable scene: a side-scrolling character, a blockout level with a lever, a door, a chest and a 30-minute chapter timer, a three-level camera zoom, and host-authoritative online co-op over ENet.

Everything is placeholder art (flat colours and gradients). There are no third-party assets.

Open the project

  1. Install Godot 4.6 or newer (standard build, no .NET needed).
  2. Start Godot, click Import, pick game/project.godot, then Import & Edit.
  3. Press F5 (Run Project). The start screen appears over the level.

The first open imports assets/tiles.png and creates the .godot/ cache folder; that folder is generated and can be deleted or ignored by git.

Run two instances (local multiplayer test)

From the editor

  1. Debug > Customize Run Instances..., enable multiple instances and set the count to 2 (or more, up to 6).
  2. Press F5. Two windows open.
  3. In one window click Host. In the other leave IP 127.0.0.1 and port 7777 and click Join.

Both windows now show the same level. Move in one and watch the other; flip the lever, open the chest, pick up the loot, and compare the timer.

From the command line

Replace godot with the path to your Godot executable. Everything after -- is read by the game itself.

godot --path game -- --host
godot --path game -- --join 127.0.0.1

--port N picks another port. The window title says HOST or CLIENT and the peer id.

Automated sync check (no window)

Run both lines at once (two terminals, or & in a POSIX shell). After a few seconds the host flips the lever, opens the chest, swings twice at the training dummy, kills Husk1 and steps onto the loot. The client runs right and jumps through its predicted character, then the host moves it next to the dummy and it swings once. At about 5 s the host drops the client in front of Husk2 (which walks into it: 15 damage and a knockback) and knocks its own player out; the client is then parked beside the downed host player and revives it with interact. Both peers print an AUTOTEST ... line with their replicated state and quit. The two lines should agree on players (host hp=50 at (3671, 1216), client hp=85 at (3691, 1216)), lever, door, chest, loot, dummy HP (dummy_hp=70 dummy_hits=3) and timer. Loot rolls are seeded in every test, so the host always gets a Rare Short Blade from the chest and Husk1 always leaves one drop on the ground (loot_nodes=1). Both print AUTOTEST-ENEMIES: Husk1 is killed by three hits and must lie at the same spot on both peers (Husk1:dead@(1450.0, 1856.0)), the other two husks and Brute1 must still be alive and walking, Spitter1 alive and stationary (walked=false), and the Warden alive and dormant (Warden:alive hp=400 walked=false). The host prints AUTOTEST-HOST hitstops=2; the client prints AUTOTEST-PREDICTION max_error=... hitstops=1, where max_error is the largest smoothed gap between its prediction and the host's result (teleport snaps not counted). Walking and jumping stay around 1 px; the hurt knockback adds a few px that blend out (about 4 px at 0 ms, 8 px at 300 ms).

Enemy attacks have their own two-peer check (run both lines at once, --lag works here too):

godot --headless --path game -- --host --spittertest
godot --headless --path game -- --join --spittertest

The host puts the client beside Spitter1, which shoots it once; during the next wind-up the host hits the spitter (cancelling the shot) and then kills it. Both print SPITTERTEST ... client_hp=85 spitter_alive=false spitter_hp=0 projectiles_seen=1 projectiles_left=0; the host adds shots=1 cancels=1. godot --path game -- --host --spittershot <path> (windowed) saves <path>_windup.png and <path>_shot.png for a visual check.

The melee enemy has the same kind of check, --brutetest on both lines. The host puts the client in front of Brute1. A hit during the wind-up must not stagger it, the swing takes the client to 75 HP, a hit during the recovery must stun it, and three more kill it. Both print BRUTETEST ... client_hp=75 brute_alive=false brute_hp=0; the host adds swings=1 armor_ok=true punish_ok=true. --bruteshot <path> (windowed, solo host) saves <path>_windup.png and <path>_swing.png.

The boss has a two-peer check too, --bosstest on both lines. The host puts both players in the arena (invulnerable, so no team wipe resets the boss) and forces each attack in turn. A slam sends two floor waves. A charge into the right wall is followed by a hit while the boss is exposed (double damage). HP is then set to half for phase 2, a five-shot volley follows, then the kill, and the host walks into the exit crystal. Both print BOSSTEST ... woke=true phase2=true waves=2 orbs=5 hud_bar=true boss_alive=false crystal=true cleared=true; the host adds slams=1 charges=1 volleys=1 exposed_damage=20. --bossshot <path> (windowed, solo host) saves <path>_slam.png, _charge.png, _exposed.png and _volley.png.

Saving gear has a two-peer check run three times, with a test profile on each line (it never touches a real save):

godot --headless --path game -- --host --profile savetest_host --savetest write
godot --headless --path game -- --join --profile savetest_client --savetest write

Weapon movesets have a two-peer check, --movesettest on both lines. The client is the attacker, so its prediction runs the combos too. The host hands it each weapon in turn, and the client mashes attack next to the dummy. Then comes a reach check from 80 px. The host prints the damage of every hit per phase: short_blade@40:[14, 14, 21] (finisher ×1.5), quick_dagger@40:[10, 10, 10, 16], war_hammer@40:[20, 28] (slam ×1.4), quick_dagger@60:[] (out of reach), war_hammer@60:[20], long_spear@40:[17, 22] (lunge ×1.3), long_spear@100:[16], chain_whip@40:[13, 14], chain_whip@110:[14] and greatsword@40:[20, 20, 32] (finisher ×1.6). Reach comes from the art (see "Hit areas from the art"), so these distances change when a weapon or a swing is redrawn; --hitboxdump (solo host) prints every weapon's hit areas. Both print 20 swings, and the client's max_error=0.0.

Air attacks have a two-peer check, --airtest on both lines. The client jumps off the floor itself and attacks, so its prediction and the bounce replay both run. Some phases lift the dummy to jump height for the air combos. The host prints the damage of every hit per phase: blade_air:[14, 17] (aerial combo, ×1.2 second hit), blade_pogo:[18, 18] (two down-strikes, bouncing off the dummy), hammer_down:[27, 20] (the plunge ×1.6, then the landing shockwave ×1.2), whip_down:[17] and dagger_air:[10, 10, 14]. The client prints its drift per phase: 0.0 at --lag 0; under lag a down-strike can show a brief correction (see Known limitations).

Dropping and destroying have a two-peer check, --itemtest on both lines. The client drops a War Hammer from its bag (and cannot take it straight back), then destroys a dagger. The unarmed host walks onto the hammer and equips it: a trade. Both print ITEMTEST ... drops_seen=1 client_bag=["fury_charm"] host_weapon=war_hammer ground_now=0. --movesetshot <path> (windowed) freeze-frames each weapon's signature swing.

The chapter flow has a two-peer check too, --chaptertest on both lines. The host kills Husk1, downs the client and revives it, then kills the Warden. Both print CHAPTERTEST-OVER ... over=true result=CLEAR cleared=true panel=true frozen=true with the same results rows (host:...kills=2,dmg=40,...revives=1, client:...downs=1...). The host then restarts, and both print CHAPTERTEST-RESET ... over=false panel=false time_ok=true enemies_ok=true boss=DORMANT lever=false door_open=false chest=false loot=0 shots=0 players_ok=true; the host adds stats_zero=true. Solo checks: --timeuptest runs the clock out (prints TIMEUPTEST ended=true/TIME UP/true rows=1 restarted=true), and --resultsshot <path> (windowed) saves the results screen.

Loot has a two-peer check too, --loottest on both lines. The host drops items at the client's feet: a Rare War Hammer (auto-equipped), then a Common Short Blade (worse, so bagged). The client equips the blade from its bag, then gets a Rare Iron Plate. The host hits the dummy as the client and hurts the client for 20, and the Warden drops its two items. Both print LOOTTEST-GEAR ... weapon=short_blade armor=iron_plate bag=["war_hammer"] atk=13 def=9 hp=89/100 loot=3 dummy_hp=87 boss_drops=2 (the client adds notices_left=0). After the host restarts, both print LOOTTEST-RESTART ... weapon=short_blade armor=iron_plate bag=1 loot=0 ground=0 hp=100/100: gear kept, ground loot cleared. --optionsshot <path> (windowed) saves the options menu and the gamepad HUD hints. --gearshot <path> (windowed) saves drops on the floor and the gear menu. --gearmenushot <path> (windowed, --give-all style bag of 17) saves <path>_bag.png (a bag weapon picked: preview and comparison), <path>_sub.png and <path>_scroll.png and prints GEARMENU bag_over_limit=true sub_rows=true scrolled=true unequipped=true dropped=true.

The gear screen has a solo headless check that drives it with the same calls its keys and the mouse make:

godot --headless --path game -- --host --gearmenutest

It prints GEARMENUTEST sorted=true nav=true tabs=true stats=true preview=true equip=true unequip=true destroy=true mouse=true seen=true, then GEARMENUTEST PASS.

A solo check of the team wipe:

godot --headless --path game -- --host --wipetest

It knocks the lone host player out and prints WIPETEST downed_ok=true ignored_hit=true respawned=true hp=100 at_spawn=true after the respawn.

godot --headless --path game -- --host --autotest
godot --headless --path game -- --join --autotest

Add --lag 150 to both lines to emulate a 150 ms round trip (the host sees client input 75 ms late, the client sees host state 75 ms late). It works without --autotest too, for feeling the netcode by hand. Test use only.

New-feature checks:

godot --headless --path game -- --host --subtest
godot --headless --path game -- --join --subtest

The client picks up all five subweapons, throws each at the dummy, tries one without MP, crouches and swings low, up and diagonally. The host prints SUBTEST host pickups:owned=5 slots=["knife", "axe", "flask", "orbs"] | knife:[14] mp=60->59 | axe:[22] ... | flask:[8, 8, 8, 8] ... | orbs:[11, 11, 11, 11, 11] ... | disc:[13, 13] ... | no_mp:[] ... | crouch:[14] ... crouched=true | up:[15] ... | upfwd:[14] ... (MP goes back up by itself between phases); the client prints worst_error= under 1 px.

godot --headless --path game -- --host --battest

prints BATTEST woke=true hurt=10 retreated=true dead=true fell=true.

godot --headless --path game -- --host --stage hollow_keep --wolftest

prints WOLFTEST patrol=true range=true ran=true readied=true leapt=true hurt=18 recovered=true dead=true: the wolf in R02 (WF1) trots both ways within its patrol, runs at a player 300 px away, crouches, leaps for 18, lands and recovers, then dies to a hit. --wolfshot <path> (windowed, same stage) saves <path>_patrol.png, <path>_ready.png and <path>_leap.png.

godot --headless --path game -- --host --stage colossus_pit --colossustest

prints COLOSSUSTEST wake=true slam=30 sweep_floor=25 sweep_ledge=0 rocks=6 floor_up=0 ledge_up=15 strained=23(normal 15) phase2=true slams=2 dead=true crystal=true. --featureshot <path> (windowed solo host) saves <path>_subs.png, or <path>_colossus.png with --stage colossus_pit.

godot --headless --path game -- --host --masterytest
godot --headless --path game -- --join --masterytest

Skill mastery (the client line is optional; it adds the sync check). Each peer uses its own test save (masterytest_host / masterytest_client, deleted before and after). The host checks that the dummy gives no points, 1 per hit and +5 for a kill, a thrown knife trains the knife, a boss kill gives 48 (2 a hit + 40), a rank-up shows the toast and the dummy then takes 11 instead of 10 (13 mastered), orbs cost 15 / 14 / 11 MP at ranks 0 / 2 / 5, a mastered knife throws two and mastered orbs have four, a mastered combo's hit area is 1.25x, the save round-trips, a version 1 save still loads, bad points are cleaned, plain gains are saved within 3 s and the mastery page lists every skill. It prints MASTERYTEST host PASS 18/18 (17 when solo); the client, given disc points by the host, prints MASTERYTEST client PASS 3/3 (replicated, toast, in its own save). --masteryshot <path> (windowed solo host) saves <path>_panel.png. The chapter map has a two-peer check, --maptest on both lines (the host picks Hollow Keep itself):

godot --headless --path game -- --host --maptest
godot --headless --path game -- --join --maptest

Before hosting, the host builds the map of every stage in Main.STAGES and prints MAPTEST-BUILD test rooms=6 boss=R06 ... | hollow_keep rooms=22 boss=R22 ... | colossus_pit rooms=1 boss=R01 ... all_ok=true (a new chapter added to STAGES is checked too). In Hollow Keep the host walks into R04 before the client joins, then puts the client in R05; each peer presses the map action (open, close, open + Esc), and the host restarts the chapter. The host prints MAPTEST peer=host start=true dark=true entered=true shared=true minimap=true bound=true toggle=true esc=true zoom=true reset=true revealed=["R01"] all_ok=true, the client MAPTEST peer=client start=true dark=true late=true shared=true ... revealed=["R01", "R04", "R05"] all_ok=true (late: R04 was found before it joined). --mapshot <path> (windowed solo host, --stage hollow_keep) saves <path>_mini.png and <path>_full.png.

The tests turn off the random heart / vial / subweapon drops and the test room's start pickups (except --subtest and --colossustest), so the older tests print the same loot as before.

The co-op features added later have their own scenarios (scripts/test_coop.gd), all in the CI runner:

All of them at once (and in CI)

tools/ci/run_tests.py runs every check above (solo and host + client) and compares the printed lines with the values written here:

GODOT=/path/to/godot python tools/ci/run_tests.py

There are two suites. The regression suite is every test (the command above, the long one). The express suite (--smoke, the SMOKE list at the top of the runner's helpers) is nine fast tests that cover the main things: the core loop, every stage building, the gear screen, loot, accessibility and the pad layout, and four host + client checks (sync, join, a chapter from start to results, saving). Run it before a push:

GODOT=/path/to/godot python tools/ci/run_tests.py --smoke

Name tests to run only those (python tools/ci/run_tests.py wolftest bosstest), --list lists them (and marks the express ones), and --logs <dir> keeps the raw output. A two-peer test that fails is retried twice, since two processes on real sockets depend on timing. A test can ask for more clients (clients=5, the load test): their logs are client, client2... and a client check must hold on every one of them. Host and client start at the same moment: the scenarios run on timers from launch, so a client started late misses the host's moves (the client's LOOTTEST-GEAR line comes out short, for one). A check marked flaky in the script is reported but does not fail the run. Today that is the subtest's flask and orbs hit counts (they sometimes also hit Husk1), its client worst_error (the axe-throw drift ranges from under 1 px to about 5 px between runs of the same build), and the clocknettest client's cogs_spinning (cog Q4 stops right as both print). When a test's expected numbers change on purpose, update both this README and the script.

GitHub Actions (.github/workflows/ci.yml) runs the express suite on every pull request, and the regression suite on every push to main, on demand (Actions > CI > Run workflow) and on pull requests that carry the full-tests label: put it on the big features (a character, an enemy, a stage, anything that touches the netcode or the saves) and leave small fixes and docs on the express suite. It runs on Linux with Godot 4.7.1, after an import and the sprite-sheet check. When a feature adds a test, add it to the regression suite (and to SMOKE only if it is fast and every change could break it). A second job exports the web build and attaches it to the run as the web-build artifact; deploying it is still deploy/web/deploy.sh.

Testing with every weapon

godot --path game -- --host --give-all

--give-all gives every player one Rare of each weapon on top of their saved gear, and all five subweapons, and lifts the 12-item bag limit (the gear menu scrolls the bag). The first one is equipped if the hand is empty; the rest go in the bag, so open the gear menu (I) to switch. It switches to the sandbox save profile (unless --profile is given), so the test weapons never reach your real save. Pass it on every window. Only the host's flag gives the weapons, but each window's flag keeps that window on the sandbox profile.

From the editor: Project > Project Settings > Editor > Run > Main Run Args (or Debug > Customize Run Instances for several windows), add --give-all. --givetest (solo host, with --give-all --profile <name>) prints what you got.

Controls

Every action works on keyboard and on a gamepad (Xbox layout names; any pad Godot knows maps the same way). The HUD's bottom line names whichever you used last. Esc / Start opens Options, where every action can be rebound.

Action Keys Gamepad
Move A / D or Left / Right Left stick or D-pad
Up (aim attacks and subweapons up) W or Up stick up or D-pad up
Crouch hold S or Down on the ground hold down
Jump, double jump Space (press again in the air) A
Triple jump, wall jump, wall run (Kestrel) Space a third time in the air; Space against a wall; jump into a wall holding toward it A
Drop through a platform hold S or Down, then jump hold down, then A
Attack J or X (in the air: aerial combo) X
Upward attack (ground or air) hold W / Up, then attack hold up, then X
Diagonal attack, up and forward hold W / Up and a direction, then attack stick up-forward, then X
Crouching attack crouch, then attack (or Down + attack on the ground) hold down, then X
Down-strike (in the air) hold S or Down, then attack hold down, then X
Down-forward lash (Ansel, in the air) hold S or Down and a direction, then attack stick down-forward, then X
Censer Spin (Ansel) hold attack hold X
Chain Hook (Ansel, in the air under a hook) hold W / Up, then attack hold up, then X
Next incense (Ansel) hold W / Up, then E hold up, then Y
Subweapon (costs MP) L or V alone; + forward, + Down, + Up for the other three (Aurel: scrolls; Silas: coin, lasso; Brunhal: bash, charge, taunt, banner; Ansel and the Wanderer: the shared subweapons; Kestrel: stars, smoke step, caltrops, fan; Coralie: spell 1, spell 2, Sanctuary, Mend; Orsolya: howl, pounce, a subweapon, moon leap, her wolf: Savage Rush; Nemo: three masks, + Down a subweapon, hold it for the Bat and Sentinel masks; Ysolde: Kiln Star, Hoarfrost Lance, Gravity Well, Tempest Crown; Tobias: hound, moths, den, crow) RT, with the stick the same way
Backdash K or C (ground only; Aurel with Ash Veil: forward dash) B
Blink (Ysolde, instead of the backdash; ground or air) K or C (mastered: hold a direction to blink that way) B
Star Bolt (Ysolde) hold attack 0.6 s hold X
Guard (Brunhal, instead of the backdash) hold K or C hold B
Roll (Kestrel, instead of the backdash) K or C, the way a direction is held (back with none) B, with the stick
Den (Tobias) hold S or Down, press L or V, keep Down held hold down, RT
Open Book (Coralie, instead of the backdash); with it open, the subweapon button (alone / + forward) turns spell slot 1 / 2 to the next spell hold K or C hold B
Turn into the wolf (Orsolya, blood gauge full) hold K or C hold B
Mule Kick (Silas) hold a direction, then backdash stick forward, then B
Reload early (Silas) hold backdash, then attack hold B, then X
Use lever / chest, revive a downed teammate E (attacks also trigger lever and chest) Y
Drink a Phial of Return (revive a fallen teammate from anywhere) hold S or Down, then E hold down, then Y
Ghost (after going down with teammates in the game) move and W / S or Up / Down fly it stick in every direction
Quick chat / emotes T opens the list, 1-9 or Up/Down + Enter send, Esc closes L3 opens, D-pad + A send, B closes
Camera zoom Tab: room -> nearby rooms -> whole map RB
Chapter map (full screen; the minimap is always in the top right) M to open/close, Esc closes LB to open/close, B or Start closes
Play the chapter again (host, on the results screen; Use sends everyone back to the Forge Hall) R X (Y: back to the Forge Hall)
Gear screen (EQUIPMENT, SKILLS, MASTERY and, at a forge, FORGE) I to open/close, arrows to move between cells, PageUp / PageDown switch page, Esc to close. On a worn piece: Enter takes it off (into the bag), Q drops it on the floor. On a bag item: Enter equips, Q drops on the floor, Del or Backspace twice destroys. On the SKILLS page: Enter on an input goes to the list, Enter on an entry puts it on the input. On the forge: Enter upgrades, E reforges, Del or Q twice salvages. Mouse: hover picks, click uses, right-click drops, wheel scrolls the bag Select to open/close, D-pad to move, LB / RB switch page, B close; A / X / Y twice the same as Enter / Q / Del (on the forge: A upgrade, Y reforge, X twice salvage)
Options (volume, accessibility, rebinding) Esc Start

W and Up used to be jump and use; they are "Up" now, so attacks and subweapons can aim upward. A saved settings.cfg from before keeps its old bindings until "Reset controls" in Options. The exception is the gamepad: when the default pad layout changes (Settings.CONTROLS_VERSION), an old file keeps its keyboard keys and takes the new pad defaults.

The pad layout follows the usual action-platformer one: A jump, X attack, B backdash, Y use, RT subweapon (with the stick for the other three), LB chapter map, RB camera zoom, Select gear screen, Start options, L3 quick chat. The "Gamepad buttons" row of the options menu names them as an Xbox pad (A B X Y, LB / RB, LT / RT, Select, Start) or a PlayStation one (Cross, Circle, Square, Triangle, L1 / R1, L2 / R2, Share, Options) everywhere the game prints a hint; the bindings themselves are the same.

Crouch and directional attacks

Subweapons, MP and drops

Five subweapons (scripts/subweapons.gd, shots in scripts/player_shot.gd):

Subweapon MP Damage What it does
Throwing Knife 3 12 fast and straight, stops at a wall or the first hit
Hand Axe 6 20 thrown up in a high arc, passes walls, hits everything once
Fire Flask 8 6 per tick lobbed; bursts on the floor (or the first enemy) into flames that burn 2.2 s
Ricochet Disc 7 11 flies out, slows, comes back to you; hits everything on the way out and back
Warding Orbs 15 9 per tick three orbs circle you for 5 s

Damage adds half your gear's ATK, and the dev damage multiplier.

Skill mastery

No XP and no levels: besides loot, a character only grows by mastering its skills (scripts/mastery.gd). Ten skills, each with its own points: the five melee attack types (Ground Combo, Aerial Strikes, Plunge, Rising Strikes for Up and Up + forward, Low Sweep) whatever weapon swings them, and each of the five subweapons.

Rank Points Damage MP cost (subweapons)
0 0 +0% -0%
1 25 +5% -0%
2 75 +10% -10%
3 150 +15% -15%
4 300 +20% -20%
5 500 +30% -25%

Damage numbers

Every hit on an enemy, a boss or the dummy pops its damage above the target (yellow for 40 and over, orange on the Colossus while it is strained). Players show damage taken in red and healing in green. Each peer makes its own from the replicated HP (scripts/damage_number.gd), so nothing extra is sent.

Options and rebinding

scenes/options_menu.tscn (scripts/options_menu.gd) opens from the title screen's Options button or Esc / Start in game. Up/Down pick a row, Left/Right change the music or effects volume, Enter / A on an action waits for the new key or button (Esc cancels). Each action keeps one keyboard and one gamepad binding there; binding an input another action used swaps the two. "Reset controls" restores the defaults from project.godot. The game keeps running underneath (it is online), the character just stands still.

The Settings autoload (scripts/settings.gd) owns the bindings and volumes and saves them to user://settings.cfg; it also tracks whether the last input came from a pad, for the HUD hints (Settings.hint("jump") -> "Space" or "A").

Accessibility

Three rows under the volumes, kept in the same settings file and applied by the Accessibility autoload (scripts/accessibility.gd) on this machine only:

--accesstest (solo host) checks all three, the settings file round trip and the music layers.

Sound and music

Every sound is original and generated by art/tools/make_audio.py (numpy + scipy, synthesis only, no samples):

python art/tools/make_audio.py

It writes the effects to game/assets/audio/sfx/ (numbered files are random variants of one sound) and the music to game/assets/audio/music/ as stems: a base loop (explore.wav, 32 s; boss.wav, 24 s) and layers of exactly the same length (explore_combat.wav; boss_phase2.wav, boss_phase3.wav). Re-run it after changing it; do not hand-edit the wav files. The layers are generated last, so adding one never changes an older file. Replace any file with a better one of the same name and the game uses it; a layer must keep its base's length.

Adaptive music. Audio.music(track, layers) plays the base and every layer of the track at once, sample-locked (each WAV is set to loop over its whole length, so they never drift), and fades each layer to 0 dB or to silence over 1.2 s as asked (Audio.MUSIC_LAYERS names them). The HUD decides every frame: exploring, the combat layer comes in while a live enemy is within 360 px of you or you were hit in the last 4 s (a muffled pulse, tense bass and ticking hats over the drone); during a boss, phase2 (hats in sixteenths, a snare on every beat, a high tremolo line) and phase3 (the Midnight Engine's last phase: a shrill arpeggio, a sub-bass and bell tolls) follow the boss's phase (is_phase2(), or phase() for the three-phase boss). The hub plays the explore base alone. A track change still crossfades over 1.5 s.

The Audio autoload (scripts/audio.gd) plays them: Audio.play("hit", position) on the SFX bus (positional, slight pitch jitter, a 40 ms repeat guard), Audio.music("boss") crossfades on the Music bus. Sounds are triggered where an event is already visible on every peer (replicated setters, the player's animation changes, an enemy's action changing), so all players hear the same thing without extra network traffic and your own jumps and swings sound at once. The HUD switches the music: explore, boss while a boss is awake, silence plus a jingle on the results screen, and raises the layers above.

What is where

game/
  project.godot          input map, autoload, 1280x720 window
  README.md
  assets/
    tiles.png            3 placeholder tiles: wall, one-way platform, background
    blockout_tileset.tres  TileSet with physics (platform tile is one-way)
  scenes/
    main.tscn            Level + Players + PlayerSpawner + WorldSync + HUD + StartScreen
    level.tscn           two TileMapLayers, LootSpawner, Lever, Door, Chest, signs
    player.tscn          CharacterBody2D, AnimatedSprite2D, hitbox, input + sync nodes, camera
    lever.tscn  door.tscn  chest.tscn  loot_pickup.tscn  training_dummy.tscn
    husk.tscn            walker enemy: patrol, contact damage
    spitter.tscn         ranged enemy: telegraphed spit
    brute.tscn           melee enemy: telegraphed armoured swing
    warden.tscn          boss: slam / charge / volley, two phases
    projectile.tscn      enemy shot, spawned by Level/ProjectileSpawner
    bat.tscn             flying enemy: perches, swoops, pulls away
    colossus.tscn        second boss: giant hand, head weak point
    colossus_pit.tscn    its arena (stage levels/colossus_pit.json)
    player_shot.tscn     a thrown subweapon
    sentinel.tscn        mini-boss: shield, guard break, bash / cleave / shards
    leaper.tscn  wisp.tscn  the hopper and the bursting flier
    hud.tscn  start_screen.tscn  options_menu.tscn
  shaders/
    color_swap.gdshader  hue shift for a second player on the same character
    colorblind.gdshader  the colour-blind filter (Accessibility)
  scripts/
    net.gd               autoload "Net": host() / join() / leave() over ENet
    settings.gd          autoload "Settings": bindings, volumes, user://settings.cfg, pad hints
    audio.gd             autoload "Audio": sound effect pool, adaptive music (base + layers)
    accessibility.gd     autoload "Accessibility": shake scale, colour filter layer, text size
    steam_lobby.gd       autoload "SteamLobby": lobbies and invites, live only with GodotSteam
    options_menu.gd      volume, accessibility and rebinding overlay
    main.gd              peer events, player spawning, chapter timer, end + results + restart, team wipe, CLI flags
    test_scenarios.gd    --autotest / --wipetest / --spittertest / --brutetest / --bosstest, screenshot helpers;
                         added under Main only when one of those flags is given
    test_coop.gd         --revivetest / --emotetest / --sentineltest / --leapertest / --wisptest / --loadtest / --accesstest
    level.gd             ASCII map painted into the TileMapLayers at startup
    player.gd            movement, double jump, backdash, attack window, interact, ghost, phials, quick chat
    player_input.gd      reads keyboard / gamepad on the owning peer only
    game_camera.gd       three zoom modes, hit shake
    map_state.gd         chapter map data from the level (rooms, tile picture) and the shared fog of war
    map_view.gd          draws the map: the HUD's minimap and the full-screen map
    net_interpolator.gd  client-side state buffer + lerp (remote players, enemies)
    enemy.gd             shared enemy core: hits, knockback, death, replication
    husk.gd  spitter.gd  brute.gd  enemy behaviours (extend Enemy)
    warden.gd            the boss (extends Enemy)
    projectile.gd        straight shot, hits decided on the host
    bat.gd  colossus.gd  the flier and the second boss (extend Enemy)
    sentinel.gd  leaper.gd  wisp.gd  the mini-boss, the hopper, the bursting flier (extend Enemy)
    rampart.gd lobber.gd shade.gd dripper.gd thrall.gd chanter.gd grinder.gd archer.gd mimic.gd burrower.gd
                         the bestiary's ten enemies (ENEMIES.md; extend Enemy)
    jailer.gd  cantor.gd  the bestiary's mini-bosses; huntsman.gd  saint.gd  its bosses
    test_bestiary.gd     --bestiarytest / --jailertest / --cantortest / --huntsmantest / --sainttest
    emotes.gd  emote_menu.gd  the quick chat list and its picker
    subweapons.gd        subweapon list, costs, drop chances
    masks.gd             Nemo's masks: drops, ranks, techniques, passives
    mastery.gd           skill mastery: ranks, points, bonuses, unlocks (tuning table)
    player_shot.gd       a thrown subweapon in flight (every peer), hits (host)
    damage_number.gd     the floating damage numbers
    hurtbox.gd           generic hit receiver for enemies
    items.gd             item database, rarities, affixes, sets, loot tables, rolls, save sanitising
    gear_save.gd         each player's gear file on their own machine (user://gear_<profile>.json)
    exit_crystal.gd      appears when the boss dies; touching it ends the chapter
    gear_menu.gd         the gear screen (worn, subweapons, bag grid, compare)
    forge.gd  chapter_board.gd  chapter_picker.gd   the hub: forge, chapter board and its list
    enemy_console.gd  enemy_picker.gd   the Enemy Arena: console that calls up any enemy, its list
    results_screen.gd    the chapter-end screen (cards, finds, badges, play again / back to the hub)
    lever.gd  door.gd  chest.gd  loot_pickup.gd  hud.gd  start_screen.gd

How the networking works

Load test: six players

--loadtest on a host and five clients (python tools/ci/run_tests.py loadtest launches all six) waits for six players, then every client plays a bot for 15 s (a new direction, a jump or a swing every few tenths of a second) while the host measures. The host prints LOADTEST host players=6 fps=... phys_ms=... proc_ms=... sent_kbps=... recv_kbps=... per_client_kbps=... enemies=... far_synced=... familiars=... (frames per second, mean physics and process frame times in ms, kilobits per second sent and received over ENet from Net.traffic, enemies on the far sync rate) and each client LOADTEST client peer=... sent_kbps=... recv_kbps=... max_error=... hp=... with its own traffic and its worst smoothed prediction error.

Measured on this machine, six headless peers on localhost, the Prologue (9 enemies), everyone fighting in the start room:

host each client
frame time 1.7 ms physics, 2.0 ms process (145 fps headless)
upload 2.3 Mbit/s (about 460 kbit/s per client) 140-185 kbit/s
download 870 kbit/s 410-490 kbit/s
prediction error 0.7-5 px (one bot that took hits mid-air: 30 px once)

So a host needs about 2.5 Mbit/s of upload for a full party and a client about 0.5 Mbit/s down, before Steam's relay. The frame budget is not a concern. Measure again after changing what is replicated; the test does not fail on the numbers, only on six players making it in.

tobiasloadtest is the same run with all six playing Tobias (--character tobias on every peer): the bots call hounds, MP never runs out, so each keeps his two familiars out (10 to 12 at once, familiars= in the host line). Against loadtest on the same machine, the same day (other worktrees running tests in parallel, so the frame times are noisy):

loadtest tobiasloadtest
host physics / process 1.9 / 2.5 ms 3.1 / 3.1 ms
host upload 2.39 Mbit/s (478 kbit/s per client) 2.69 Mbit/s (539 kbit/s per client)
client download 450-500 kbit/s 525-545 kbit/s

So eleven familiars cost about 60 kbit/s per client (each sends one Vector4 20 times a second, Familiar.SYNC_INTERVAL, where an enemy sends five properties every frame) and about a millisecond of host physics.

Level editing

scripts/level.gd holds the level as an ASCII map (# wall, - one-way platform, . open, P spawn point). It is painted into the Ground and Background TileMapLayers when the scene starts, so edit the text to change the layout, or paint directly in the editor with blockout_tileset.tres and delete the map once you no longer need it. Lever, door and chest are ordinary nodes in level.tscn positioned in pixels (tile size 32). That test room stays the default level.

The Prologue (the built-in level)

The level in scenes/level.tscn (stage key test, the hard-coded MAP in level.gd) is the intro chapter, Prologue: The Outer Ward, as in the old games: one short run from the gate to a first boss. It is also where most headless tests run, so its layout and props stay as they are. It has no stage JSON, so its chapter-map rooms come from Level.PROLOGUE_ROOMS: The Outer Gate (start), Lever Room, Husk Passage, Lower Gallery, Chest Loft (optional) and The Warden's Hall (boss). It is first on the hub's chapter board. On every map the boss marker sits where the boss waits (its placed spot), not at the middle of its room.

Stages (Hollow Keep)

A stage is a level built entirely from a JSON file exported by the blockout tool ("Export to Godot" > "Copy stage JSON"). scenes/hollow_keep.tscn uses the same level.gd with stage_path = res://levels/hollow_keep.json: the JSON map replaces the hard-coded one (extra tiles: = crumbling platform, ^ spikes, A arrow trap in a wall; other letters are entity markers), and every entities[] and doors[] entry is instanced at pos_px (feet) and named by its id (Z1, L3, D5), so all peers get the same node paths.

Type Scene
zombie, brute, spitter, boss husk, brute, spitter, warden (.tscn)
hound hound.tscn (fast runner, sprints at players in sight)
rampart, thrall, chanter, shade, grinder, burrower, mimic the bestiary (ENEMIES.md): rampart, thrall, chanter, shade, grinder, burrower, mimic (.tscn); levels/add_bestiary_hollow_keep.py appended seven of them to the JSON (idempotent; the JSON has no generator)
key, lever, timer, plate, block, chest key, lever, timer_switch, pressure_plate, push_block, chest
arrow, crusher arrow_trap, crusher
= / ^ map runs crumble_platform, spike_hazard (one node per run)

Door rules run on the host (Level._update_gates) and each door replicates is_open: key unlocks for good when the key's carrier reaches it, lever follows its levers (gate levers latch on), plate is open while a push block sits on the plate (lever L6 sends the block home), timer is open while the switch counts down, puzzle is open while L2-L4 are all lit (a pull flips a lever and its neighbours) and seals while the boss fights.

Pick the level on the start screen (Stage) or with --stage hollow_keep. Only the host chooses: a joining client is told the host's stage on connect and swaps its level before anything replicates. To add a stage, drop its JSON in levels/, copy hollow_keep.tscn with the new stage_path, and add it to STAGES in main.gd.

Stage check (solo, or add a --join --stagetest client; the STAGETEST-DOORS lines must agree):

godot --headless --path game -- --host --stage hollow_keep --stagetest

It prints spawn_room=R01, then STAGETEST-GATES with every door rule, spikes and a crumbling slab =true, and STAGETEST-RESET ok=true after a chapter restart.

Chapter 2: The Clock Tower

scenes/clock_tower.tscn with levels/clock_tower.json, 31 rooms in a tower 120 x 156 tiles, climbed from the foot (R01) to the boss arena at the top (R31). The JSON is generated: edit levels/make_clock_tower.py and run python game/levels/make_clock_tower.py; it refuses to write a tower where a room or the boss cannot be reached (the same jump-aware walk --clocktest runs). Route: key K1 (West Shaft) opens hatch D1, the cog chain in the Gear Chain Shaft opens D3, the dials in the Dial Gallery (hours on the plaque in the Archive of Hours) open boss door D4. D2 and D5 are one-way lever shortcuts down.

The new types set themselves up from their props in stage_setup(entity, room) (Level calls it when a node has it). Everything clockwork follows the TowerClock (type clock): the host replicates its time a few times a second and every peer runs it forward, so the hazards below need no syncing.

Type What
pendulum PendulumBlade: crescent blade swinging from a pivot (length, swing, period, offset)
gearwheel GearWheel: gear lift, level one-way platforms going round on a rim (AnimatableBody2D, riders are carried)
clockhand ClockHand: bladed hand stepping round a pivot on every tick
tickblock TickBlock: one-way blocks there on every other beat (phase 0 / 1 swap each tick)
cog CogSwitch: a TimerSwitch that only starts if its driven_by cog spins; door gate chain
dial ClockDial: a strike moves its hand an hour; gate_open() when it shows target
clue CluePlaque: the hours the dials want, drawn on the wall
windup WindupKnight: winds up (key spins), charges, runs down (2x damage while spent)
swinglamp Swinglamp: lantern on a chain; swings wider at players, drips embers
tickwraith Tickwraith: blinks behind a player on the tick, slashes on the next
midnight MidnightEngine (boss)
grinder, thrall, archer, mimic, shade, burrower, dripper, chanter the bestiary (ENEMIES.md): one each, from the Pinion Walk to the Dial Gallery
jailer Jailer: the Iron Jailer, mini-boss of the Gear Lift Hall (R21)

Doors may carry "latch": true (stays open once its rule opened it); a gate's needs may name any node with gate_open().

The Midnight Engine hovers over the arena; only its face can be hit (jump or upward attacks, or from the ledges). Sweep: the minute hand glows on one side, then sweeps that half from straight up to straight down (26): get to the other half. After a sweep it is overwound: it sags low and takes 1.5x. Gears roll along the floor both ways (jump them). Phase 2 (below two thirds, it tolls and fires a ring) adds the chime ring and a blink to another part of the arena; phase 3 (below a third) shortens everything, follows each sweep with the hour hand on the other half, and rains gears. 720 HP, always armoured; leash, team-wipe reset and exit crystal as for the other bosses.

godot --headless --path game -- --host --stage clock_tower --clocktest

prints CLOCKTEST-LOAD ... rooms=31 ... missing=[] spawn_room=R01, CLOCKTEST-REACH rooms=31/31 unreached=[] doors_opened=["D1", "D2", "D3", "D4", "D5"] boss_room=true, CLOCKTEST-TRAPS pendulum=22 hand=18 gear_lift=true tick_block=true, CLOCKTEST-GATES (every door =true), CLOCKTEST-ENEMIES windup=true swinglamp=true tickwraith=true and CLOCKTEST-RESET ok=true.

godot --headless --path game -- --host --stage clock_tower --midnighttest

prints MIDNIGHTTEST wake=true door_sealed=true sweep=26 dodge=0 overwound=15(normal 10 low=true) gears=3 phase2=true toll=true chime=10 ... phase3=true double_sweep=true ... rain=10 phases=[1, 2, 3] dead=true crystal=true. --clocknettest (host with --stage clock_tower, plus a --join --clocknettest client) prints a CLOCKNETTEST line per peer that must agree, with clock_ok=true. --clockshot <path> (windowed) saves screenshots of a few rooms and the whole map.

Playable characters

Eleven characters (scripts/characters.gd; design doc "Castle Crawler - Playable Characters"). Names are placeholders. Pick one on the start screen (Character row, kept in the settings) or with --character aurel|silas|wanderer|brunhal|ansel|kestrel|coralie|orsolya|nemo|ysolde|tobias. A character may have its own base stats ("stats" in Characters.DEFS, read through Characters.base; also "jumps", read by Player.max_jumps, a base "crit" chance, and an "armor" list, Characters.armor_of): the eight new ones do; Aurel, Silas and the Wanderer use Player's constants (100 HP, 60 MP, 1 MP/s, 280 px/s, 2 jumps, 10 ATK, 0 DEF, no crit). Enemies have one shared slow (Enemy.slow(seconds, share): Kestrel's Caltrops, Ansel's Ash incense, Ysolde's Hoarfrost Lance, Tobias's Moth Swarm) and two kinds of light hit (Enemy.take_soft_hit, Ansel's incense: no knockback; Enemy.familiar_hit, Tobias's pack: no hit-stop either). The host learns it with the player's hello and replicates Player.character, which picks the sprite sheet and the kit. Two players may pick the same one: the second is drawn with a color swap (shaders/color_swap.gdshader, a hue shift).

The Ironbound Wanderer (the Meshy model) plays the original way: one weapon of any kind (two-handed ones included) and the four subweapons.

Aurel Vane (dual wield, scrolls)

Silas Crane (gunslinger)

Brunhal Asker (the tank; scripts/bulwark.gd, class Bulwark)

Ansel Marrow (the censer, a Belmont-style whip; scripts/censer.gd, class Censer)

Kestrel Ashby (the scout; scripts/wallrunner.gd, class Wallrunner)

Coralie Thorne (the healer; scripts/binder.gd, class Binder)

Orsolya Drav (the shapeshifter; scripts/moonblood.gd, class Moonblood)

Nemo, the Faceless (the mask collector; scripts/masks.gd, class Masks)

Mask Technique (MP) Rank 5 Passive (mask 1)
Husk Grab (6): seizes the enemy in front and throws it (Enemy.kicked, wall slams too) throws 1.5x as far, stuns longer +10 max HP
Bat Glide (4 a second): hold the button in the air, falls at 70 px/s at most 2 MP a second falls slower (70% gravity, 640 px/s at most)
Spitter Acid Spit (8): a gob lobbed about 6 tiles (acid shot) three gobs in a fan enemy orbs (spit) do half damage
Brute Ground Slam (12, on the ground): a blow at his feet and a shockwave rolling out each way (quake shots) the waves roll twice as far hits of 15 or less do not knock him back
Leaper Leap (8): a jump about 8 tiles high, striking on the way up higher still +1 jump
Wisp Blink (10): 3 tiles forward, never into a wall, briefly untouchable 4.5 tiles +0.3 MP/s
Wolf Lunge (8): a 600 px/s dash with a bite the bite stuns 1 s +5% speed
Sentinel Bone Shield (0): hold the button on the ground; a hit from the front lets 40% through (Sentinel.BLOCK_SHARE), no knockback, enemy shots stop on it; he turns but does not walk 20% through +2 DEF

The Warden (the boss) has no mask yet: to be designed. Grab, the slam, the leap and the lunge are attack chains (Player.moves("grab" / "slam" / "leap" / "lunge")), simulated and predicted like swings; so are the blink, the glide and the shield (mask_held set by the button, ended by the mask_release action PlayerInput sends when it is let go). The throws, stuns, shots, blocks and MP are decided on the host. One cooldown is shared by the techniques. Mask chains train no mastery: masks rank up by copies. - Mastery: the melee tracks and the subweapons, like the Wanderer.

Ysolde Vey (the mage; scripts/starwright.gd, class Starwright; her spells fly as StarShot, scripts/star_shot.gd)

Tobias Wren (the summoner; scripts/beastwarden.gd, class Beastwarden; his familiars scripts/familiar.gd, class Familiar)

Checks (solo host, all in tools/ci/run_tests.py): --character silas --kittest (KITTEST ...), --character aurel --duelisttest (DUELISTTEST), --character brunhal --bulwarktest (BULWARKTEST), --character ansel --censertest (CENSERTEST), --character kestrel --wallruntest (WALLRUNTEST; Kestrel's tests are in scripts/test_wallrunner.gd, on a test wall they build under the Prologue's high ceiling), --character coralie --bindertest (BINDERTEST), --character orsolya --moonbloodtest (MOONBLOODTEST), --character nemo --masktest (MASKTEST), --character ysolde --ysoldetest (YSOLDETEST) and --character tobias --beastwardentest (BEASTWARDENTEST). --kitshot <base> (windowed) saves three frames of the character's kit. Host + client checks: bulwarknettest, wallrunnettest, moonbloodnettest, ysoldenettest, beastwardennettest, censernettest, bindernettest and masknettest, below.

Brunhal on a client (host + client): --host --bulwarknettest with --join 127.0.0.1 --character brunhal --bulwarknettest. The client raises her guard (the guard_on / guard_off actions, predicted); the host lands a front hit (2 HP through, no knockback, no hurt reaching the client), stops an enemy shot on her shield and breaks her guard, which the client hears through guard_state and the shield comes back up on both peers; then the client's Shield Bash (the husk hit and stunned, 5 MP) and Ram Charge (about 225 px, the husk thrown, 10 MP) run as predicted bash / charge chains. Each peer prints BULWARKNETTEST peer=... ... and BULWARKNETTEST host|client PASS; the client also prints its prediction error per step (0.0 px) and the gap to the host's position at rest (0.0 px). The steps wait on each other's flags (an RPC on the test node), not on timers.

Kestrel on a client: --host --wallrunnettest with --join 127.0.0.1 --character kestrel --wallrunnettest. Both peers build the same test wall; the client runs up it, slides, jumps off, rolls back and forward, Smoke Steps and Dive Kicks, all predicted; the host checks its own simulation of each (the counters, 8 MP for the step, the down chain). The client prints its prediction error per move (0.0 px in the runs so far; with --lag 120 on both peers 3.7 px at worst, on the wall, still under the 4 px bar) and WALLRUNNETTEST host|client PASS.

Orsolya on both peers: --host --character orsolya --moonbloodnettest with --join 127.0.0.1 --character orsolya --moonbloodnettest. The host fills the client's gauge; the client holds backdash and the host turns her (turned, replayed on the client: the wolf sheet and the turn animation there too); she mauls the dummy (four hits, predicted); the host's own Orsolya turns and the client draws her as a wolf (net_wolf); the client's form runs out on both peers, then the host's. Each peer prints MOONBLOODNETTEST peer=... ... and MOONBLOODNETTEST host|client PASS, with the prediction error per step (0.0 px) and the gap at rest (0.0 px).

Ysolde on a client: --host --ysoldenettest with --join 127.0.0.1 --character ysolde --ysoldenettest. The client blinks on the ground and in the air (predicted: 96 px back on both peers, untouchable on the host), then casts a Kiln Star and a Star Bolt, which the host decides and the client sees fly. Each peer prints YSOLDENETTEST peer=... ... and YSOLDENETTEST host|client PASS (prediction error 0.0 px, no gap at rest).

Tobias on a client: --host --beastwardennettest with --join 127.0.0.1 --character tobias --beastwardennettest. The client calls a hound and a moth swarm; the host simulates them (two bites, the swarm stuck to the husk, two out), the client sees both appear, the hound move (drawn about 70 px behind the newest host state while it runs: the interpolation delay) and the swarm sit on the husk, then both leave on both peers. Then the client's Den, predicted there: a 20 hit costs 10 HP on the host, no hurt reaches the client, and letting Down go brings him out (0.0 px prediction error). Prints BEASTWARDENNETTEST peer=... ... and BEASTWARDENNETTEST host|client PASS.

Ansel on a client: --host --censernettest with --join 127.0.0.1 --character ansel --censernettest. The host gives him all three incenses; Up + Use on the client burns the next one (decided on the host, back through GearSync). His spin is predicted on the client; on the host it leaves an Ember cloud (twice the damage, its incense seen on the client), stops an enemy orb and hurts the husk. Then a jump and Up + attack under a hook point added on both peers: the chain hook, predicted, lifts him about 75 px. Prints CENSERNETTEST peer=... ... and CENSERNETTEST host|client PASS (prediction error 0.0 px, no gap at rest).

Coralie on a client: --host --bindernettest with --join 127.0.0.1 --character coralie --bindernettest. The client holds the open book (predicted); the host lands a husk's hit on it (3 HP through, no hurt reaching the client), binding Grasping Page, and stops a spitter's orb on it, binding Bile Script; bound / bound_slots reach the client. With the book open the client's button turns the page (Paper Bats into slot 1, nothing cast). With Leaping Verse in slot 1 the book closed, the verse runs as a predicted chain (about 245 px, the husk hit, 10 MP); Bile Script (+ forward) is cast on the host and its shot seen on the client; Mend (+ up) heals her and the host's player beside her by 20, both seen on the client. Prints BINDERNETTEST peer=... ... and BINDERNETTEST host|client PASS (prediction error 1 px at worst, no gap at rest).

Nemo on a client: --host --masknettest with --join 127.0.0.1 --character nemo --masknettest. The host gives him the Husk, Wisp and Bat masks; the client sees masks / mask_slots and the Husk Mask's +10 HP. Grab (the button alone) is predicted and throws the husk on the host (6 MP); the client then wears the Wisp Mask in slot 1 (assign_mask, as the gear menu does), which comes back to it with the HP gone; Blink moves him 96 px on both peers (untouchable on the host, 10 MP); the Glide (Up + the button held in the air) caps his fall at 70 px/s on both, costs MP on the host and ends when the button is let go (mask_release). Prints MASKNETTEST peer=... ... and MASKNETTEST host|client PASS (prediction error 0.0 px, no gap at rest).

The character art is built in Blender without Meshy (wiki/art/art-pipeline.md, "Playable characters"): block-out bodies on the Meshy rig, so every animation is shared.

Replacing the placeholder art

The player's AnimatedSprite2D is a SheetSprite; Player._apply_look sets its Atlas Path from Characters.DEFS. player.gd only calls sprite.play(anim), and any animation a sheet lacks falls back to idle (required_animations).

level.tscn has the lighting: an Ambient CanvasModulate darkens the world, a MoonLight DirectionalLight2D gives every normal-mapped sprite a soft top-left key light, and a warm SpawnTorch PointLight2D sits in the start room. Tiles have no normal maps yet, so they are lit flat.

Importing Blender sprite sheets

Layered sheets (one sheet per costume piece, kind: layered_sprite, made by tools/art_pipeline/) go through the same two commands and are shown by LayeredSprite (scripts/layered_sprite.gd); scenes/seated_table_test.tscn is their viewer. See wiki/art/blender-dioramas-pipeline.md.

art/tools/render_spritesheet.py writes a colour sheet, a normal sheet and an atlas .json to art/renders/<name>/ (see wiki/art/art-pipeline.md). To bring them in:

godot --headless --path game --script res://tools/sync_sprite_sheets.gd
godot --headless --path game --import
godot --headless --path game --script res://tools/check_sprite_sheets.gd

The first command copies every sheet into assets/sprites/<name>/ (pass names after -- to sync only some). The second imports the PNGs. The third builds every atlas and prints SPRITECHECK OK or the problems it found.

To use a sheet, add an AnimatedSprite2D, attach scripts/sheet_sprite.gd (class SheetSprite) and set Atlas Path to the .json. It then:

scenes/sprite_sheet_test.tscn shows the test dummy in a dark room under an orbiting light (F6 in the editor). Add -- --screenshot <path> on the command line to save a PNG and quit.

Known limitations

Enemies

The full list, with stats, where each one appears and its loot table, is in enemies.md. The sections below cover the implementation.

The Enemy Arena (testing one enemy at a time)

scenes/enemy_arena.tscn (stage key enemy_arena, "Test: Enemy Arena" on the start screen and the hub's chapter board) is one open room with the Colossus Pit's ledges and an enemy console by the spawn:

godot --path game -- --host --stage enemy_arena

Walk to the console and press use (E). The panel lists every enemy in EnemyConsole.ROSTER; arrows or the mouse pick one and Enter calls it up about 1000 px to the right (fliers under the ceiling, the Colossus against the right wall), far enough that it has not seen you yet. The panel's last row: clear all enemies, heal + revive party, and god mode (players take no damage; off when you leave the arena). There is no chapter timer; a team wipe gets everyone up at the spawn. The bosses wake at once, since the whole room is their arena.

Co-op works: anyone can use the console (a client asks the host), and the enemies come through a MultiplayerSpawner under the console, so every peer and late joiners see them. levels/make_enemy_arena.py writes the stage JSON; scripts/enemy_console.gd and scripts/enemy_picker.gd are the console and its panel. Checks: --arenatest (host + client: every enemy spawns, reaches the client and stays in the room; god mode, heal, clear) and --arenashot <path> (windowed solo host, --stage enemy_arena: the picker and a few enemies). A new enemy needs one line in ROSTER (and its spawn spot in spawn_enemy if it does not stand on the floor).

All enemies extend scripts/enemy.gd (class Enemy). It does everything the same for every enemy: gravity, taking hits through a child Hurtbox (hit-stop, knockback, stun), death (flung, fades, stops), the HP bar, the hit flash, and replication. Sync sends net_facing, net_action, hp, alive and a frame-stamped net_state, which must stay last. Clients draw enemies through NetInterpolator. A subclass sets its numbers in _init() and overrides _behave(delta) (host AI), _on_hit() and _present() (visuals on every peer). The host-only check runs every tick, because level enemies exist before a client connects, while it still counts as a server.

Husk

scenes/husk.tscn, placed three times under Level/Enemies: one on the start-room floor, one in the middle room, and one on a short platform there, which shows ledge turning.

Spitter

scenes/spitter.tscn, one in the lever room (Spitter1, x 400), so it fires on players coming up the shaft from the start room.

Brute

scenes/brute.tscn, one in the lever room (Brute1, x 1000). Turning at ledges keeps it between the shaft from the start room and the right wall, so it guards the lever while the Spitter covers the shaft.

The Warden (boss)

scenes/warden.tscn, at x 4600 in the boss room (THE WARDEN'S HALL). It is an Enemy with 400 HP that is always armoured: hits never knock it back or stun it.

Bat

scenes/bat.tscn, three in the test room (lever room, chest loft, the hall past the door) and five in Hollow Keep (Ossuary, Library, Bell Tower, Chapel). An Enemy with flying set: no gravity while alive, it falls when it dies, and it flies through one-way platforms.

The Sunken Colossus (second boss)

scenes/colossus.tscn in its own stage, Boss: The Colossus Pit (--stage colossus_pit, or pick it on the start screen). A giant built into the right side of the pit; it never moves. Only its head can be hurt, and the head is far above the floor: climb the ledges (first ledges 4 tiles up, top ledges 8 tiles up; the right top ledge reaches under the head) and hit it with an upward swing or a jump attack. 600 HP, always armoured.

To add an enemy type, extend Enemy, give its scene Body, Hurtbox, Bar/Fill and a Sync with the five properties, and put the AI in _behave(). Walkers can use _wall_ahead() and _ground_ahead() from the base; return true from _armored() while hits should not stagger.

Ossuary Sentinel (mini-boss)

scenes/sentinel.tscn (scripts/sentinel.gd, group miniboss), SN1 on the Ossuary floor of Hollow Keep (R10), between the second spike pit and the hounds. Not a chapter boss: no arena, the chapter goes on when it dies, and its loot comes from the miniboss source (one item of Uncommon or better, 25-40 gold, a Phial of Return 60 % of the time). The HUD shows a smaller bar above the boss bar's spot while it is engaged (is_active).

Leaper

scenes/leaper.tscn (scripts/leaper.gd), LP1 in the Barracks and LP2 in the Chapel of Hollow Keep. A grave toad that sits still until a standing player it can see is within 300 px and not far above it, crouches for 0.35 s (it flattens and its eyes brighten), then leaps in an arc at them: touching a player in the air deals 12. It lands flat for 0.6 s (open to hits), then waits 0.9 s before the next leap. A hit while crouching cancels the leap. 25 HP.

Wisp

scenes/wisp.tscn (scripts/wisp.gd), WS1 in the Library and WS2 in the Bell Tower of Hollow Keep. A grave-light: it drifts in a slow circle around its spot until a player is within 360 px, then homes in at 95 px/s, flickering faster as it nears. Within 30 px of a player it bursts: 18 damage to every player within 72 px, and it is gone. Any hit lights it (it bursts 0.3 s later, so step away), and a killing hit sets it off at once: hit it from range or run. 10 HP.

The bestiary: ten more enemies, two mini-bosses, two bosses

Added on 2026-10-18 (scripts/test_bestiary.gd checks them; the full table with stats, counterplay and loot is in enemies.md). All are code-drawn block-outs like the older enemies, placed by stage type (Level.ENTITY_SCENES) and met in Bestiary: The Menagerie (--stage bestiary, levels/make_bestiary.py: eight pens in a row) and the two boss arenas (levels/make_boss_arenas.py).

Loot and gear

Power comes only from gear (no XP). scripts/items.gd (class Items) holds the item data, the rarities and the loot tables.

Source Chance Items Rarity
Chest always 1 Uncommon or better
Husk 30% 1 any
Spitter 40% 1 any
Brute 60% 1 any, a bit luckier
The Warden always 2 Rare or better

Only the host rolls, with Items.rng. Tests seed it so results repeat. - Drops. Enemies drop where they die (Enemy._drop_loot), and chests above themselves. A drop is a loot_pickup spawned by Level/LootSpawner with the item in its spawn data, showing a gem in the rarity colour and the item's name. - Pickup. The first standing player to touch a drop gets it (Player.give_item, host). It is equipped if its slot is empty or it scores better than what is worn (the old piece goes to the bag); otherwise it goes to the bag (12 slots). With a full bag the drop stays on the ground. The new owner sees a popup naming the item and whether it was equipped. - Gear screen (scripts/gear_menu.gd, class GearMenu, drawn in code by the HUD). I toggles it. Two tabs in the header, EQUIPMENT and MASTERY (see Skill mastery). On EQUIPMENT, left: the three worn pieces as cards (icon, stats, the weapon's moveset), the four subweapon inputs as tiles, and the stats (HP, ATK, DEF, SPD) with a green/red before -> after preview of what the picked cell's action would do. Right: the bag as a 6 x 2 grid (more rows scroll), shown sorted by slot, then rarity, then score (GearMenu.sorted_order; each tile maps back to the real bag index the host knows). Tiles wear their rarity colour, a green arrow when they beat the worn piece of their slot (Items.score) and NEW until the screen is closed once. Under the bag a details card compares the picked piece with the worn one stat by stat. Arrows / D-pad move spatially between all cells; Enter equips (Player.equip_from_bag, an RPC to the host) or takes off; the mouse works too. Icons are placeholders: weapons come from the weapon sheet, armour, charms and subweapons are drawn shapes until an item icon sheet exists. Movement keys are ignored while it is open. - Replication. inventory and equipped are host-owned and synced by GearSync every 0.2 s (also to late joiners). Every peer computes the same stats from equipped (swing_damage(), defense(), max_hp(), move_speed()), so a client's predicted running speed matches the host's. - Chapter restart keeps everyone's gear but clears loot from the ground and resets the pickup count. Chests and enemies drop again on the next run: the farming loop. - HUD: HP / max HP, ATK, DEF, speed % and the chapter's pickup count. The gear screen also names the equipped weapon's moveset. - Dropping and destroying (gear menu, bag items only). Q asks the host to drop the selected item (Player.drop_from_bag). It lands on the floor in front of you as a normal pickup that anyone can take, which is also how players trade. The player who dropped it cannot pick it back up for 1.5 s, and only by stepping onto it again. Del (or Backspace) pressed twice within 2 s destroys it (Player.destroy_from_bag); the menu shows the confirmation in red. Both go through the host and update the owner's save. - Weapons in the hand. The equipped weapon is drawn as its own sprite (Player/Facing/Weapon). - It comes from assets/sprites/weapons/ (SpriteSheet.weapon_sprite: colour + normal map, so lights shade it). - Every frame, the body sheet's socket for the frame on screen (SheetSprite.socket_now()) places the weapon's grip, sets its angle, and puts it in front of or behind the body. - It mirrors with the body through Facing, and hides when unarmed or downed. - Remote players show it too, from the replicated gear and animation. - Each swing plays its own body animation and stretches it to the swing's length, so a hammer blow and a dagger jab both play the whole arc. Every combo step names a swing type (Items.SWING_ANIMS, e.g. the blade goes slash, rising, thrust; the hammer goes overhead, slam), and the sheet's atk_<type> animation is used. A sheet without it falls back to its plain attack. - Player.show_hit_areas (default on) flashes each swing's hit area in red. Set it to false to judge the art. --swingshot <path> (windowed, solo host) saves every weapon's full ground combo frame by frame without the flash. - --weaponshot <path> (windowed, solo host) captures every weapon standing and mid-swing, facing both ways. - Hit areas from the art. A weapon swing's hit area is not hand-written: Player.step_hitbox builds it from what is drawn. - Swept area. For each frame of the step's swing animation that is shown while the hit window is open, it places the weapon sprite's four corners at that frame's socket (grip and angle). The box around all of them, plus 4 px (HITBOX_MARGIN), is the hit area. Results are cached per weapon, chain and step. - Time-warped playback. The swing animation is driven by hand, not stretched evenly. The wind-up plays before the window opens, the animation's strike part (the action's strike range, set in animate_attacks.py) plays across the window, and the recovery plays after it. The weapon visibly connects exactly while the hit area is live. - Reach follows the art. Redrawing a weapon or a swing changes its reach. Today, from the feet: dagger about 38 px, blade 50-58, hammer 61, spear 91, whip 105. - Exceptions. Unarmed swings and the landing shockwaves (auto steps) keep the box written in Items.MOVESETS. - Weapons change the attack itself (Items.MOVESETS, chosen by the base's moveset). A moveset is a chain of steps, each with its own swing time, active window, hit area (size and offset), damage multiplier and how much you may move during it:

Weapon Moveset
none one strike, 0.32 s, 56×44 hit area
Short Blade 3-hit combo: two 0.26 s slashes, then a 0.36 s thrust with a long, thin hit area (88×24) for ×1.5
Quick Dagger 4 jabs of 0.17 s at ×0.8 and a ×1.2 last one, short reach (46×28), half run speed while striking
War Hammer slow 2-hit chain: a 0.55 s overhead whose tall hit area (76×72) also covers above you, then a 0.62 s ground slam, wide and low (108×30), for ×1.4
Long Spear a long, thin thrust (110×20, reaches about 120 px), then a lunging thrust that pushes you forward (420 px/s, bleeding off) for ×1.3
Chain Whip the longest reach: a 0.42 s lash (150×16, about 160 px), then an upward lash that covers the space above you (120×60) for ×1.1
Greatsword 3 slow, wide arcs (90×70) with a 0.66 s finisher (110×80) for ×1.6; slows you while carried

Pressing attack during a step buffers the next one, which starts the moment the current one ends. Pressing it within the moveset's window after a step ends also continues the chain. After the last step, or once the window closes, the next press starts over. Every step re-arms the hit area, so each hit of a combo can land on the same target. Each player has its own copy of the hit-area shape. The combo runs in _step, so the host and a predicting client run it identically on the same input ticks. Other players see the right swing through the replicated net_attack_step. Hits deal swing_damage() × step mult (Player.current_hit_damage). - Hit detection is polled, not signalled. At the start of every simulated tick the host hits whatever the active hit area overlaps that the current swing has not hit yet (Player._scan_hits). This catches a target that stays inside from one step to the next (a plunge followed by its shockwave), and works however many ticks the host simulates per frame. - Air attacks. Every moveset also has an air chain and a down move:

Weapon In the air Down + attack in the air
none a kick stomp; bounces
Short Blade 2-hit aerial combo (second ×1.2) downward stab, ×1.2; bounces
Quick Dagger 3 quick slashes (last ×1.1) dive stab; bounces highest
War Hammer big aerial overhead ×1.2 meteor plunge ×1.6, no bounce, then a 140 px landing shockwave ×1.2
Long Spear long aerial thrust plunging spear that reaches below your feet, ×1.3; bounces
Chain Whip 2 lashes, the second angled down a lash straight down below you (no plunge)
Greatsword a spin that hits both sides, ×1.2 plunge ×1.5, then a 160 px landing shockwave ×1.3

Rich loot: affixes, secondary stats and sets

On top of the bases above, Items.rich_loot (on in play; off for the older headless tests, whose seeded rolls and expected numbers predate it) adds:

Check (solo, headless):

godot --headless --path game -- --host --richloottest

It prints RICHLOOTTEST affixes=true names=true merge=true sanitize=true roundtrip=true setname=true set2=true set3=true crit=true leech=true mpregen=true subpower=true luck=true and RICHLOOTTEST PASS. --lootshot <path> (windowed solo host) saves <path>_set.png and <path>_affix.png.

Joining a game: names, versions and the lobby

Playing over the Internet today, before Steam: see the step-by-step guide below ("Tester en ligne avec des amis").

Checks (in the CI runner): --jointest refuses a client when the game is full (--max-players 1) or the version differs (--fake-version), lets a named one in, and shows the unreachable message (--join to a port with no host); --lobbytest (host --stage hub, client --name Bob) checks names, the party line, the ready flow and the reset in the hub; --emotetest (same lines) the quick chat. --lobbyshot <path> (windowed, --stage hub) saves the start screen and the board.

Tester en ligne avec des amis (guide pas à pas)

En attendant Steam, le plus simple est Tailscale : un réseau privé virtuel gratuit qui met vos ordinateurs « sur le même réseau », sans ouvrir de port sur la box. Avec la version Windows il faut Tailscale (ci-dessous) ; la version web se joue aussi à plusieurs avec un simple code de partie (voir « Jouer à plusieurs dans le navigateur »).

1. Préparer le build à envoyer (toi, l'hôte)

Le zip contient CastleCrawler.exe et un README.txt en français pour tes amis (copie de deploy/desktop/README-friends.txt).

Envoie ce zip à tes amis (Discord, Google Drive, WeTransfer...). Tout le monde doit avoir le même build : la version est écrite sous l'écran d'accueil, et le jeu refuse un joueur qui n'a pas la même (« Version mismatch »). Après chaque nouvelle version, renvoie le zip.

2. Installer Tailscale (tout le monde, une seule fois)

  1. Toi : crée un compte sur https://tailscale.com et installe Tailscale sur ton PC (icône « Connected » dans la barre des tâches).
  2. Invite tes amis : console Tailscale (https://login.tailscale.com) > Users > Invite users, ou Share ta machine avec eux.
  3. Chaque ami installe Tailscale, se connecte et accepte l'invitation.
  4. Note ton adresse Tailscale : clic sur l'icône Tailscale > ton ordinateur > l'adresse qui commence par 100. (ex. 100.84.12.7). Donne-la à tes amis.

3. Lancer une partie

  1. Toi (l'hôte) : lance CastleCrawler.exe, écris ton nom dans Name, choisis The Forge Hall (hub), clique Host. Si le pare-feu Windows demande, autorise les réseaux privés (sinon personne ne peut entrer).
  2. Tes amis : lancent CastleCrawler.exe, écrivent leur nom, mettent ton adresse 100.x.x.x dans IP, laissent Port à 7777, cliquent Join. Windows peut dire que l'appli n'est pas signée : Informations complémentaires > Exécuter quand même.
  3. Dans la Forge Hall, la liste PARTY en haut à gauche montre qui est là. Chaque ami va au tableau CHAPTERS (à droite), appuie sur E puis Entrée : il passe READY.
  4. Toi, au même tableau : choisis le chapitre. Si quelqu'un n'est pas prêt, le jeu te le dit ; appuie une 2e fois pour lancer quand même.
  5. À la fin du chapitre : R pour rejouer, E pour revenir tous à la Forge Hall (l'or et l'équipement sont gardés, chacun dans sa sauvegarde).

Jouer à plusieurs dans le navigateur (version web)

La version web se joue aussi à plusieurs, sans rien installer :

  1. L'hôte ouvre https://castle.veridianresonance.com, écrit son nom, choisit The Forge Hall (hub) et clique Host. Un code de partie de 5 lettres s'affiche en haut à gauche (ROOM CODE VPXBT).
  2. Les amis ouvrent la même adresse, écrivent leur nom, tapent le code dans Code et cliquent Join.
  3. Ensuite, tout est pareil qu'en version Windows : groupe, READY au tableau, chapitres.

À savoir :

Comment ça marche : les navigateurs ne font pas d'UDP, donc ils se connectent directement entre eux en WebRTC (scripts/web_lobby.gd, WebRTCMultiplayerPeer). Pour se trouver, ils passent d'abord par /signal sur le même site (deploy/web/worker.js) : un Durable Object Cloudflare par code de partie, qui numérote les joueurs et transmet les offres de connexion, puis plus rien ne passe par lui. deploy/web/deploy.sh --prefix beta met une version à essayer sous /beta/ sans toucher à celle en ligne (le Worker, lui, est commun).

4. Si ça bloque

Message en bas de l'écran d'accueil Quoi faire
Version mismatch Pas le même build : renvoie le même zip à tout le monde.
Could not reach the host Vérifier l'adresse 100.x, que Tailscale est « Connected » chez les deux, que l'hôte a cliqué Host, et que le pare-feu de l'hôte autorise le jeu (réseau privé).
The game is full 6 joueurs maximum.
The connection to the host was lost L'hôte a quitté ou sa connexion a coupé : relancer.
Could not host on port 7777 Un autre jeu (ou une 2e copie) utilise déjà le port : fermer l'autre, ou changer le Port (les amis mettent le même).

Sans Tailscale, l'autre option est d'ouvrir le port UDP 7777 sur la box de l'hôte (redirection vers son PC) et de donner aux amis son IP publique ; ça dépend du fournisseur et ne marche pas toujours (CGNAT).

The hub: Forge Hall, gold and the forge

Between chapters the party gathers in The Forge Hall (stage hub, levels/hub.json written by levels/make_hub.py; regenerate, do not hand-edit). It has no clock, no enemies and no chapter end; the HUD shows HUB instead of the timer.

Every action is an owner -> host RPC (Player.forge_upgrade, forge_reroll, forge_salvage); the host checks the owner, the distance to a forge and the gold. Forge levels are saved with the item and clamped to 0..5 by Items.sanitize. - The chapter board (stage type board): use it to open the chapter list (Main.CHAPTERS). The host picks; Main.go_to_stage sends everyone there: every peer swaps its level in the same RPC (so node paths match before anything else replicates), and the host puts each player at the new spawn with a fresh clock. The same call brings everyone back to the hub. - The start screen lists every stage, the hub included, three to a row.

Two-peer check (the host starts in the hub):

godot --headless --path game -- --host --stage hub --hubtest
godot --headless --path game -- --join --hubtest

The host gives the client gold and gear; the client tries the forge from too far (refused), then upgrades, reforges and salvages next to it, is refused an upgrade with 1 gold, and finds its gold in its save; the host opens the client's forge screen and its own chapter list, sends everyone to Hollow Keep, ends it as CLEAR (+100 gold each) and brings everyone back. The host prints HUBTEST peer=host hub=true board_ui=true chapter=true reward=true back=true, the client HUBTEST peer=client hub=true far_refused=true upgrade=true reforge=true salvage=true poor_refused=true saved=true forge_ui=true chapter=true reward=true back=true, each then PASS. --forgeshot <path> (windowed, --stage hub) saves <path>_start.png, <path>_hall.png and <path>_forge.png.

Chapter end, results and restart

Clients follow through the replicated properties. A revived enemy turns visible again and re-enables its hit areas on every peer (Enemy._on_revived).

Two-peer check (the host in Hollow Keep):

godot --headless --path game -- --host --stage hollow_keep --endtest
godot --headless --path game -- --join --endtest

The host hands the client a Rare Short Blade, the Throwing Knife and knife mastery rank 1, takes an Epic Fury Charm and three kills itself, ends the chapter as CLEAR, and then presses use on the results screen. Both print ENDTEST peer=... shown=true client_card=true host_card=true footer=true to_hub=true and PASS (the client's card: +100 gold, both finds, "Throwing Knife R1"; the host's: +106 gold and Most kills; everyone back in the Forge Hall with their gold). --endshot <path> (windowed, --stage clock_tower) saves <path>_clear.png (three cards) and <path>_timeup.png.

Player HP, damage and downed

Hit feel