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
- Install Godot 4.6 or newer (standard build, no .NET needed).
- Start Godot, click Import, pick
game/project.godot, then Import & Edit. - 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
- Debug > Customize Run Instances..., enable multiple instances and set the count to 2 (or more, up to 6).
- Press F5. Two windows open.
- In one window click Host. In the other leave IP
127.0.0.1and port7777and 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
- write: each peer deletes its test save. The host gives the client a
Rare War Hammer and a Common Short Blade, and itself an Epic Vigor Charm.
Each peer prints what its own file now holds
(
SAVETEST-WRITE peer=client ... equipped=["war_hammer"] bag=["short_blade"]). - read: a fresh session. Both peers print everyone's restored gear, the
same on both (
client{Weapon:war_hammer/r2/+14 ATK -7% SPD bag=["short_blade"] ...} host{Charm:vigor_charm/r3/+46 HP ... hp=146/146}). - tamper: the client first rewrites its save with a War Hammer claiming
ATK 9999 and an item that does not exist. The host must clamp the hammer to
the most an Epic can roll (
+25 ATK -5% SPD) and drop the fake item (bag=[]).
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:
--revivetest(host + client, Prologue): the client is knocked out, rises as a ghost afterPlayer.GHOST_DELAY, takes no damage and flies up on its own Up key; the host revives it from across the room with a Phial of Return (two carried at most, kept when nobody needs one), then with Aurel's Last Rite (refunded out of reach, half HP in reach, full HP at rank 9); the chat log on both peers names the phial and the rite. Host:REVIVETEST peer=host downed=true ghost=true ghost_safe=true flew=true phials=true phial_used=true revived_afar=true nobody=true rite_far=true rite_near=true rite_full=true chat=true; client:REVIVETEST peer=client ghost_seen=true back=true chat=true; bothPASS.--emotetest(host--stage hub, client--name Bob): the client's quick message shows as a bubble over its character and a chat line on both peers; a second one inside the cooldown is dropped by the host; an emote shows a bubble and no chat line; the emote menu opens (the character stands still) and sends; an out-of-range index is ignored; the host's own message goes the same way.EMOTETEST peer=host first=true limited=true emote=true menu=true invalid=true own=trueandEMOTETEST peer=client first=true emote=true menu_open=true menu_closed=true own_seen=true.--abbeytest(solo host,--stage silent_abbey): chapter 3's enemies are wired (husks in scholar rags, the abbey clock for the tickwraiths, swinglamps, spitters) and killing the Ossuary Sentinel latches door D1 open (the "slain" gate) so the exit crystal in the niche clears the chapter.ABBEYTEST peer=host scholar=true clock=true tickwraiths=true swinglamps=true spitters=true door_closed=true exit_lit=true woke=true dead=true door_open=true cleared=true.--sentineltest,--leapertest,--wisptest(solo host,--stage hollow_keep): the Ossuary Sentinel mini-boss (wake and the HUD's mini bar, a blocked front hit for 4, a back hit for 10, the guard break after six blocked hits and a knockback while staggered, bash 18, cleave 30, three shards, enrage, sleep when left alone, death loot), a leaper (sits, readies, leaps for 12, recovers, dies) and the wisps (one chases and bursts for 18, one bursts when hit from range).SENTINELTEST peer=host woke=true bar=true blocked=4 guard=true back=10 back_unblocked=true stagger=true stagger_hit=true bash=18 cleave=30 shards=3 enraged=true slept=true dead=true loot=true,LEAPERTEST peer=host sat=true hurt=12 readied=true leapt=true recovered=true dead=true,WISPTEST peer=host chased=true burst=true hurt=18 gone=true lit=true safe=true.--bestiarytest,--jailertest,--cantortest(solo host,--stage bestiary),--huntsmantest(--stage huntsman_glade),--sainttest(--stage saint_nave): the bestiary (see "The bestiary" under "Enemies" and ENEMIES.md). Each forces the enemy's attacks in turn and prints what they did to the player:BESTIARYTEST peer=host rampart_block=true rampart_back=10 rampart_stagger=true rampart_shove=14 thrall_fuse=true thrall_burst=28 thrall_dead=true lobber_lob=true lobber_cancel=true archer_aim=true archer_bolt=true shade_veiled=true shade_solid=true shade_claw=16 shade_linger=true dripper_ceiling=true dripper_drip=true grinder_wind=true grinder_roll=true grinder_hit=16 burrower_buried=true burrower_under=true burrower_erupt=20 burrower_surfaced=true chanter_heal=true chanter_flee=true chanter_bone=true mimic_shut=true mimic_lunge=22 mimic_dead=true mimic_loot=true,JAILERTEST peer=host woke=true bar=true stomp=22 waves=2 rest_open=true lash=24 hook=true hook_pull=true recoil=true enraged=true slept=true dead=true loot=true,CANTORTEST peer=host woke=true hover=true bar=true armored_air=true wail=8 shriek=3 dive=20 grounded=true kneel_hit=true enraged=true wail_enraged=12 dead=true loot=true,HUNTSMANTEST peer=host wake=true charge=32 stagger=true leap=26 leap_waves=2 volley=5 dismount=true on_foot=true sweep=24 throw=2 dash=20 howl=10 phases=[1, 2, 3] volley3=7 dead=true crystal=trueandSAINTTEST peer=host wake=true hover=true pillar=24 pillar_between=0 halo=10 shatter=8 shatter_falls=true descent=22 kneels_low=true kneel_hit=true choir=12 phase2=true gaze=26 gaze_side=true gaze_other=0 phase3=true mirror=true double_halo=20 phases=[1, 2, 3] dead=true crystal=true.--bestiaryshot <path>(windowed,--stage bestiary) saves<path>_jailer.pngand<path>_cantor.png.--loadtest(host + five clients, all--loadtest): see "Load test: six players" under the networking section.--accesstest(solo host): the accessibility options and the music layers; see "Options and rebinding" and "Sound and music".ACCESSTEST peer=host shake_off=true shake_half=true filter=true filter_off=true text=true text_back=true saved=true music_layers=true explore=true combat=true calm=true pad_playstation=true pad_xbox=true pad_layout=true(the last three: the PlayStation button names and their saving, the Xbox ones, the default pad layout).
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
- Crouch. Hold Down on the floor: the body drops to 38 px tall (from 52)
and you cannot walk, only turn. Standing back up waits for head room. The
sheet has a
crouchpose (a wide low stance); without it the standing frames are squashed instead. - Directional swings. Every weapon gets three more single-step swings,
built from its first ground step by
Items.directional_moves(), so the reach matches the weapon: up: Up + attack, on the ground or in the air, strikes above the head (x1.1). Reach is the weapon's forward reach turned upward.upfwd: Up + forward + attack, a diagonal cut up and ahead. It turns you the way you hold.crouch: attack while crouching (or Down + attack on the ground), a low thrust along the floor.- In the air, Down + attack is still the down move (plunge, pogo).
Their hit areas are authored in
directional_moves(not measured from the weapon art like the other swings). They playatk_up,atk_upfwdandatk_crouchfrom the sheet (falling back toatk_rising/atk_thrust). - Netcode. The held direction travels with the press (
attack_up,attack_upfwd,attack_down; Up / Down + Use likewise:interact_up,interact_down), and the crouch input (PlayerInput.vertical) with the movement input, so prediction and replays agree with the host. (A host catching up several input ticks at once steps them all with the newestvertical, so a direction read from it at a press can be missed.)
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.
- Four inputs. The subweapon button alone, + forward, + Down and + Up each carry one subweapon. You can own all five; four are on the inputs. A new one goes on the first free input; in the gear menu (I) the SKILLS tab lists the inputs on the left and every subweapon found on the right: pick an input, then pick from the list (picking one already on another input swaps them). Characters whose kit takes over the button have no inputs there; the tab lists their kit instead.
- Finding them. The test room has all five lying around the start area
(two on the first ledge, one on each of the next two, one on the floor
past the dummy). In Hollow Keep each of the five chests holds one. Enemies
and other chests sometimes drop one (
Subweapons.DROP_CHANCE). Picking up one you have gives 15 MP. They are kept between sessions with your gear (subweaponsandsub_slotsin the gear save). - MP. 60 max. It refills by itself at 1 MP a second (not while downed), and from mana vials. Using a subweapon without the MP shows "Not enough MP". The HUD shows HP and MP on the stats line, an MP bar under it, and the four inputs with their cost ("!" when you cannot afford it).
- Drops. Every enemy death can also drop a heart (22%, +15 HP) or a mana vial (28%, +15 MP). A full player walking over one leaves it for someone else. They blink and vanish after 15 s.
- Netcode. The host decides the throw (MP, cooldown) and spawns the shot
through the level's ProjectileSpawner; every peer flies it the same way and
only the host decides what it hits (
Hurtbox.hit(attacker, amount, from, skill)).
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.
- Points come from using a skill on enemies, decided on the host in
Enemy.take_hit: 1 per hit (2 on a boss), +5 for the killing hit (+40 for a boss). The training dummy gives nothing: it never dies and cannot fight back, so standing at it would turn mastery into a chore. - Ranks 0 to 5, shared thresholds, each a total (not a step):
| 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% |
- Mastered (rank 5) also unlocks something: melee types reach 25% further (the hit area scales from the feet); Twin Knives (two at once); Twin Axes (a second on a long low arc); Wildfire (flames 1.6x wider, burn 1.5x longer); Great Disc (1.5x size); a fourth Warding Orb.
- Tuning is all in
Mastery.RANKS,Mastery.POINTS, theMASTERED_*constants andMastery.SKILLS(names and unlock texts). - Showing it. A rank-up pops a gold toast. The gear screen (I) has a MASTERY tab (move up onto the tabs, then Left/Right, LB / RB, or click it), split into sub-tabs (melee, the kit's techniques, subweapons) so the cards always fit: every skill with five rank pips, a bar and the points to the next rank, and what the next rank gives (or, gold once mastered, what it gives now). The SKILLS tab's list and details show the MP cost and damage with mastery included.
- Netcode and saving.
Player.mastery(skill -> points) is host-owned and replicated by GearSync like the gear. The owner keeps it in its own gear save (version 2;GearSave.migratereads version 1 files as no points) and sends it when joining; the host cleans it (Mastery.sanitize: known skills, 0..500). A rank-up saves at once, other gains every 3 s.
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:
- Screen shake (0 to 100 %): every
GameCamera.shakeis scaled by it; at 0 the camera never moves on hit-stop. - Colour filter: off, protanopia, deuteranopia or tritanopia. A
full-screen shader layer over the game and the UI
(
shaders/colorblind.gdshader) daltonizes the picture: the colours that type of colour blindness merges are simulated and the lost difference is shifted into the channels the player can see, so the red HP bar, the blue MP bar, rarity colours and enemy telegraphs stay apart. - Text size (80 % to 150 %): the window theme's default font size is
scaled, every font-size override under the HUD and the start screen is
re-applied scaled (
Accessibility.scale_text(root)remembers each node's base size, so it never compounds), and the code-drawn panels (gear screen, results, chapter board, quick chat) askSettings.font_size(base).
--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
- Host-authoritative. The host simulates every player and decides all movement, collisions, attacks and interactions. A client also simulates its own character (prediction, below), but the host's result always wins.
- Players are spawned by
PlayerSpawner(aMultiplayerSpawner) underMain/Players; each node is named after its peer id. Insideplayer.tscn: PlayerInputbelongs to the owning peer. ItsInputSyncsynchronizer sends the move axis and an input tick counter (seq) to the host; jump, attack, backdash and interact go to the host as reliable RPCs (Player.request_action) and are applied locally at the same moment.ServerSync(authority: host) replicatesnet_anim,net_facing,net_ack(the last inputseqthe host applied),net_state(position plus the host's physics frame) and the loot count, all shipped with the spawn. Keep that order: thenet_statesetter consumes the others.- The host steps a client's character once per input tick that client
sent (
seq), catching up at most 4 ticks per frame when inputs arrive in a bunch and waiting when none arrive. One-shot actions carry the tick the client applied them on and are held until the host reaches it. This keeps "host state for tick N" equal to "prediction for tick N". - Your own character on a client is predicted. It runs the same
_physics_processon local input with no wait, and records its position after every input tick. When host state for ticknet_ackarrives, the difference from the recorded position is folded in (20% per update, instantly under 1 px, a snap over 96 px for teleports). - Other players on a client are interpolated. Host states are buffered by
host frame and drawn 100 ms behind the newest one, lerped between the two
states around that time, with a clock that runs at real time and drifts
toward the target to soak up jitter. Animation and facing come from the
same buffered states. Tuning constants are at the top of
player.gd. - World state is owned by the host and replicated with
MultiplayerSynchronizernodes: the chapter timer onMain(WorldSync),is_onon the lever andis_openedon the chest. The door is not replicated itself; it listens to the lever's replicated property on every peer, so it opens everywhere at once. - Loot is created by the host through
Level/LootSpawner.spawn(data), which runs the same spawn function on every peer. When the host frees the pickup, the spawner despawns it on the clients. - What goes out every tick and what goes out on change.
ServerSyncsendsnet_anim,net_facing,net_ackandnet_stateevery network tick (mode "always", unreliable, one packet) and everything else (hp,mp,downed,ghost,loot,blink_count, the attack step and set,revive_phials) only when it changes (mode "on change", reliable). Enemies do the same: facing and the frame-stamped state always,net_action,hpandaliveon change. An enemy further thanEnemy.FAR_SYNC_DISTANCE(1400 px) from every player drops its state rate to five a second (FAR_SYNC_INTERVAL), checked every 30 ticks; it is back at full rate as soon as someone comes near. ENet packets are compressed with the range coder on both ends (Net.COMPRESSION).
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)
- Two hands. A one-handed weapon in each hand (
Items.OFFHAND, key 3 ofequipped). The combo alternates hands, each step with that hand's timing, reach and swing (atkl_*animations for the left hand, the second weapon layer drawn at the left-hand socket), then a cross-cut finisher (x1.4). Two copies of one base ("Matched") swing 10% faster. War hammer, spear and greatsword are two-handed: with one in the main hand the off hand idles. A one-handed pickup fills an empty off hand; equipping from the bag replaces the weaker of the two. - Scrolls (
scripts/scrolls.gd). Three slots on the subweapon button (alone or + forward, + up, + down). Each cast costs MP; a copy found again ranks it up (to 9; unlocks at 5 and 9). Ash Veil, Grave Lance and Crow Choir come with a new save; Red Tithe, Pale Ward and Night Brand drop as loot (while an Aurel plays). Ash Veil is cast with the backdash button: a forward dash through enemies, untouchable while it lasts.
Silas Crane (gunslinger)
- Twin revolvers. Three bases (Duelist, Thunder, Sparrow) that drop like
any weapon while a Silas plays; a new save starts with a Common pair. Attack
fires one bullet, right then left gun (
player_shot.gdkind "bullet"); up, up-forward and crouching shots aim that way; Down + attack in the air is the hammer fan (up to 6 rounds down, pogo on a hit). - 12 rounds, then a reload (1 s spin). Attack again in the middle of the spin is a perfect reload: loaded at once, the next 6 shots +25%. A missed try adds 0.4 s. Backdash + attack reloads early.
- Dead Man's Coin (subweapon button alone, 6 MP): a coin hangs in the air; a bullet through it ricochets to the nearest enemy as a x2 hit, or on to the next coin (+50% a link). A ricochet into a kicked (stunned) enemy doubles.
- Hangman's Lasso (subweapon + forward, or + up at 45 degrees, 10 MP):
pulls Silas to a hook point (
LassoHook, placed under the ceilings of tall rooms), pulls a small enemy to his feet, tears a big one's armor off for 4 s, or drags a downed teammate to him. Catching nothing refunds half. - Mule Kick (forward + backdash): big knockback, a long stun, a wall slam (+50%) if the enemy hits a wall, and 2 rounds loaded.
- Mastery: his revolvers, coin, lasso and kick have their own tracks (rank 3 widens the reload window, mastered gives 14 rounds, longer coins, a longer rope, harder kicks). Down + subweapon keeps a found subweapon.
Brunhal Asker (the tank; scripts/bulwark.gd, class Bulwark)
- Base stats: 140 HP, 40 MP, 0.75 MP/s, 245 px/s, 11 ATK, 3 DEF.
- Weapons: her maces (Flanged Mace, Morning Star, moveset
mace: three heavy blows) drop like any weapon while a Brunhal plays; she can also hold the Short Blade, nothing else. A new save (or empty hands) gets the first weapon she can hold from the bag, or a Common Flanged Mace. The tower shield is part of her kit, not loot: it is always drawn in her left hand. - Guard (hold backdash; she has no backdash): the shield goes up on the ground between swings. She cannot walk, only turn. A hit from the front lets 25% through (then her armour), no knockback; an enemy shot that reaches the shield stops there, which also covers teammates behind her. Six blocked hits (eight mastered) break the guard for 1.2 s; the meter empties after 2 s without one. Perfect Parry: the guard raised less than 0.15 s (0.25 mastered) before the hit: no damage, a melee attacker in front is stunned 1 s, a shot flies back as her own bullet.
- Subweapon button: Shield Bash (alone, 5 MP: a shove that stuns 1 s,
1.5 mastered, and loads a Sentinel's guard), Ram Charge (+ forward, 10 MP,
ground: 520 px/s shield first, nothing gets through from ahead, enemies
thrown back), Rally Banner (+ up, 20 MP, 12 s cooldown: 8 s, 12 mastered,
of +3 DEF and 2 HP/s for everyone within 4 tiles), Taunt (+ down, 8 MP:
enemies within 10 tiles go for her for 6 s and she takes 70% damage;
bosses only when mastered, 3 s). Bash and the charge are attack chains
(
Player.moves("bash" / "charge")), simulated and predicted like swings; the guard is simulated too (guard_on/guard_offactions fromPlayerInput), the blocks, banner and taunt are decided on the host. Enemies askEnemy.taunter()before their own target search. - Mastery: the melee tracks plus Guard, Perfect Parry, Shield Bash, Ram Charge, Rally Banner and Taunt.
Ansel Marrow (the censer, a Belmont-style whip; scripts/censer.gd,
class Censer)
- Base stats: 110 HP, 50 MP, 1 MP/s, 270 px/s, 10 ATK, 1 DEF. The censer's ATK also grows by one per rank of his Ground Combo mastery.
- Weapons: his censers (Iron Censer, Pilgrim's Censer, moveset
censer: three lashes of about four tiles, authored reach) drop like any weapon while an Ansel plays; he can also hold the Chain Whip, nothing else. A new save gets the first weapon he can hold from the bag, or a Common Iron Censer. - Aim: forward, up, up-forward, low, and in the air straight down and
down-forward (
downfwd, his alone: Down + forward + attack in the air). - Censer Spin (hold attack 0.35 s;
PlayerInputsendsspin): the censer whirls around him for 0.6 s (1.5 s cooldown, no MP), stopping enemy orbs within 64 px, and leaves an incense cloud (PlayerShotincense, 3 s, radius 56 px, 80 mastered) that hurts every 0.4 s with soft hits (Enemy.take_soft_hit: no knockback, so enemies stay in it). - Incense (one burning; Up + Use changes it): Myrrh (teammates in the
cloud +2 DEF; a new save has it), Ember (the cloud hurts twice as much), Ash
(enemies inside move at 70%,
Enemy.slow_timer). Ember and Ash drop from brutes, chests, mini-bosses and bosses while an Ansel plays (Censer.roll_drop, pickup kindincense); the owned ones and the burning one are kept in the gear save'skit. - Chain Hook: Up + attack in the air with a hook point (
LassoHook) within 6 tiles of straight up (8 mastered) pulls him up under it. - Subweapons: the five shared ones on the four inputs, like the Wanderer. Mastery: the melee tracks, Censer Spin, Chain Hook and each subweapon.
Kestrel Ashby (the scout; scripts/wallrunner.gd, class Wallrunner)
- Base stats: 75 HP, 50 MP, 1 MP/s, 320 px/s, 3 jumps, 9 ATK, 0 DEF, 10% crit.
- Gear: the Quick Dagger (Dagger Flurry: four quick jabs, moving while
striking) or the Short Blade; the Leather Coat or Hunter's Garb; any charm
(
Items.WALLRUNNER_GEAR). A new save (or empty hands) gets the first blade she can hold from the bag, or a Common Quick Dagger; armour she cannot wear goes to the bag. - Walls (in the air, any wall): pushing into one while falling slides down it at 120 px/s at most; jump against it to push off (340 px/s away, a full jump up, no jump used; the stick is ignored for 0.16 s); rising into it while holding toward it runs up it at 300 px/s for 0.5 s (0.8 mastered), once per jump and again after each wall jump.
- Roll (backdash button): 700 px/s for 0.2 s the way the stick is held (back with the stick at rest; about 240 px with the slide out), untouchable, so she goes through enemies and shots.
- Dive Kick (Down + attack in the air): a kick diving at 45 degrees (640 px/s) until it lands or hits; a hit bounces her up and gives a jump back. Mastered: no bounce, she goes through and hits each enemy twice.
- Backstab: +50% damage on an enemy hit from behind (from the side it does not face; it turns to face whoever hits it).
- Subweapon button: Throwing Stars (alone, 6 MP, 0.4 s: three straight
stars, five mastered), Smoke Step (+ forward, 8 MP, 1.5 s: a teleport 4
tiles ahead, or up to a wall, in a cloud, untouchable 0.35 s; mastered: the
clouds blind nearby enemies for 1 s), Rising Fan (+ up, 8 MP, 0.8 s: five
stars fanned upward, seven mastered), Caltrops (+ down, 6 MP, 2 s: spikes on
the floor for 6 s, 10 mastered, that hurt every 0.4 s and slow enemies to
45% speed). The wall moves, the roll, the Dive Kick (her
downchain,Wallrunner.DIVE) and the Smoke Step are simulated inPlayer._stepand kept in_capture(the predicting client replays them); the stars, fan, caltrops, blinding and backstab are decided on the host. - Mastery: the melee tracks (Dive Kick instead of the plunge) plus Throwing Stars, Smoke Step, Rising Fan, Caltrops and Wall Run (a hit within 1 s of leaving a wall).
Coralie Thorne (the healer; scripts/binder.gd, class Binder)
- Base stats: 95 HP, 80 MP, 1.25 MP/s, 275 px/s, 9 ATK, 0 DEF.
- Weapons: her tomes (Iron-Bound Tome, Chained Codex, moveset
tome: two book slams, x1.1 and x1.5) drop only while a Coralie plays, and she holds nothing else. A tome's ATK grows by 1 for every spell she has bound (Binder.tome_bonus; the HUD's ATK shows it, the gear screen's does not). A new save (or empty hands) gets a tome from the bag or a Common Iron-Bound Tome. Armour: Leather Coat, Chain Vest and Hunter's Garb only (Items.BINDER_ARMOR; anything else goes back to the bag); every charm. - Open Book (hold backdash; she has no backdash): the tome opens in front
of her on the ground between swings (the weapon layer draws
open_tome). She cannot walk, only turn. A hit from the front lets 25% through (then her armour), no knockback; an enemy shot that reaches the book stops there. It costs 2 MP a second (1 mastered) and no MP comes back while it is open; at 0 MP it snaps shut for 0.8 s. - Binding: a blocked hit (or shot) has a 25% chance (
Binder.capture_chance) to bind the attacker's attack into a spell: new, or +1 rank if known (to 9; ranks 5 and 9 unlock more), once per enemy type per chapter (Player.bound_this_chapter, cleared on a chapter restart). The enemy is the one standing where the hit came from (Binder._attacker); shots carry their shooter's class (Projectile.source). Husk: Grasping Page (6 MP, a paper hand that holds the first enemy 1 s); Bat: Paper Bats (8, three homing bats); Spitter: Bile Script (8, an acid arc that leaves a burning puddle); Brute: Heavy Folio (12, a shockwave along the floor); Leaper: Leaping Verse (10, she leaps forward and lands striking, a predicted attack chainverse); Wisp: Lantern Ink (10, a lantern that follows her 8 s and shoots the nearest enemy); Wolf: Howling Margin (10, enemies within 4 tiles thrown back); Sentinel: Shard Index (14, a fan of five shards). Bosses and the other enemies bind nothing yet. Spells areplayer_shot.gdkindspage,paperbat,bile,folio,lantern,ink,shard. - Subweapon button: bound spell slot 1 (alone), slot 2 (+ forward); a new
spell fills an empty slot, and with the book open the button (alone or +
forward) turns that slot to the next spell she knows. Mend (+ up, 15 MP,
2 s: 20 HP to her and every teammate within 4 tiles; mastered, Vigil: the
ghosts in the circle get back up at 25 HP). Sanctuary (+ down, 12 MP, 4 s:
a bubble on the nearest standing teammate within 8 tiles, or herself, that
absorbs 30 damage for 6 s, 50 mastered;
Player.ward_hp, works on any character). Bound spells, turning the page, Mend and Sanctuary run on the host; the book is simulated like Brunhal's guard (the sameguard_on/guard_offactions andPlayer.guarding). Spells and slots are saved in the gear save'skit(bound,bound_slots) and replicated by GearSync. - Mastery: the melee tracks plus Open Book, Mend and Sanctuary.
Orsolya Drav (the shapeshifter; scripts/moonblood.gd, class Moonblood)
- Base stats: 115 HP, 40 MP, 0.75 MP/s, 285 px/s, 11 ATK, 1 DEF.
- Gear: the Long Spear only (its moveset; a new save or empty hands gets a
spear from the bag, or a Common one). Armour: Leather Coat, Chain Vest and
Hunter's Garb only (
"armor"inCharacters.DEFS, checked byItems.usable_by; anything else she wears goes to the bag); every charm. - Subweapon button (human): Howl (alone, 10 MP, 4 s: small enemies within
5 tiles are pushed back and flee 2 s,
Enemy.fear; mastered, medium ones too), Pounce (+ forward, 10 MP, 1.5 s, ground: a leap that lands just short of the nearest enemy ahead within 9 tiles, a x1.5 hit on landing; mastered, a landing that hits leaps on to the next one), Moon Leap (+ up, 8 MP, 1.2 s: about 8 tiles straight up with an upward spear strike; it uses up the double jump, mastered it gives it back), a found subweapon on + down (a new one goes there). - Blood gauge (0-100,
Player.blood, host-owned and replicated, on the HUD's kit line): +4 for every hit she lands (the training dummy counts, so the wolf can be tried there), +10 for a kill, -2 per second once 3 s pass without a hit landed or taken. - Turn: hold backdash with a full gauge (0.3 s; a tap is still a backdash): 0.6 s of transformation (untouchable, no control), then the wolf for 12 s (15 with Maul mastered), the gauge draining with it; then she is the woman again. Going down ends the form.
- Wolf: its own sheet (
"wolf_atlas"), 360 px/s, swings at her ATK x 1.1, +5% crit and +5% lifesteal on top of her gear, the same HP. Attack: Maul (four fast bites and claws,Moonblood.MAUL); subweapon button in any direction: Savage Rush (no MP, 0.9 s: a dash through enemies, about 6 tiles, 8 mastered, untouchable while it runs); backdash: Bound (760 px/s for 0.25 s, back). No items: loot, subweapons, mana and phials stay on the ground, and she cannot drink a phial; hearts and gold are still picked up (the HP is shared). She keeps her armour's DEF. - Network: the form is simulated (
Player.wolf,wolf_timer,turn_timer, in_capture/_restore). The host decides the Turn at the start of a tick (Moonblood.tick_host) and tells the owner withPlayer.turned(seq, seconds), which replays from that tick, so the form starts and runs out on the same tick on both; the held button arrives asmoon_on/moon_off. The other peers seenet_wolf(ServerSync) and switch the sheetINTERP_DELAYlater, with the interpolated body. Pounce, Moon Leap, Maul and Savage Rush are attack chains (Player.moves("pounce" / "moonleap" / "maul" / "rush")), predicted like swings; the Howl and the gauge are host-only. - Mastery: the melee tracks plus Howl, Pounce, Moon Leap, Maul and Savage Rush, and the five subweapons.
Nemo, the Faceless (the mask collector; scripts/masks.gd, class Masks)
- Base stats: the default ones. Weapons: the three one-handed ones
(Short Blade, Quick Dagger, Chain Whip), no kit weapons; any armour but Iron
Plate (so not the Vigil Plate either); every charm (
Items.usable_by). A weapon or armour he cannot use goes from the body to the bag; empty hands take the first weapon he can hold from the bag. - Masks drop from the enemies he fights: a killed enemy drops its own mask
(by its class: husk, bat, spitter, brute, leaper, wisp, wolf, sentinel) 4% of
the time, times (1 + the killer's luck); a mini-boss or boss always drops
its mask while no Nemo in the game owns it yet. Masks only drop while a Nemo
plays (
Masks.drops, set by main.gd), never in the older headless tests, and only Nemo picks them up (loot kindmask,Player.give_mask). A copy ranks the mask up, to 9; past 9 a copy gives MP. Rank 5 improves the technique, rank 9 doubles the passive. A new save starts with the Husk Mask. Saved in the gear save'skit(masks,mask_slots). - Three worn (
Player.mask_slots): the subweapon button alone casts mask 1's technique, + forward mask 2's, + up mask 3's; + down is a found subweapon. Mask 1 also gives its passive while worn. A new mask goes to the first free slot; the gear screen (I) shows the three in the subweapon row (Enter cycles a slot through the masks found, swapping slots) with the subweapon on + down, and a details card (technique, passive, rank 5 and 9).
| 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)
- Base stats: 80 HP, 90 MP, 1.5 MP/s, 270 px/s, 8 ATK, 0 DEF.
- Gear: her staves (Rowan Staff, Orrery Staff, moveset
staff: two short strikes) drop like any weapon while a Ysolde plays; she holds nothing else. A new save (or empty hands) gets the first staff in the bag, or a Common Rowan Staff. Armour: only the Leather Coat and the Hunter's Garb (Items.STARWRIGHT_ARMOR; anything else goes back to the bag); every charm. Each rank of her five spells' masteries adds 1 ATK to the staff (Starwright.staff_bonus). - Star Bolt (hold attack 0.6 s, 3 MP;
PlayerInputsendsstar_boltonce per hold): a straight bolt through the first target, stopping at the second (mastered: through all). - Subweapon button (each spell its own cooldown, decided on the host):
Kiln Star (alone, 10 MP, 0.8 s: a fireball lobbed in an arc that bursts on
what it meets, everything within 2 tiles hit; mastered: fire on the floor
for 2 s), Hoarfrost Lance (+ forward, 12 MP, 0.7 s: through every enemy,
each slowed 40% for 2 s,
Enemy.frost_timer; mastered: two lances), Tempest Crown (+ up, 15 MP, 1.2 s: three lightning orbs rise over her and home in, each on one of the nearest enemies; mastered: five), Gravity Well (+ down, 18 MP, 3 s: a vortex 3 tiles ahead pulls every non-boss enemy within 5 tiles in for 2 s,Enemy.well_timer; mastered 3.5 s). Spell damage is its base plus half the gear ATK, like a subweapon's, with its mastery bonus. - Blink (backdash button, ground or air, 1 s cooldown): a teleport 3
tiles back (mastered: 4 tiles, the way the stick is held), stopping short
at a wall, then 0.15 s still and untouchable (animation
blink, drawn faint). Simulated and predicted like the backdash (blinking_timer,blink_cooldownin the replay snapshots). - Surcharge: a spell she lacks the MP for takes the MP left and the rest in HP (1 HP per 2 MP), as long as she keeps at least 1 HP; otherwise it is not cast. The HUD shows the HP price instead of the MP one.
- Mastery: the melee tracks plus Star Bolt, Kiln Star, Hoarfrost Lance, Tempest Crown, Gravity Well (points per enemy caught) and Blink (points when an enemy is within 4 tiles).
Tobias Wren (the summoner; scripts/beastwarden.gd, class
Beastwarden; his familiars scripts/familiar.gd, class Familiar)
- Base stats: 90 HP, 75 MP, 1.25 MP/s, 280 px/s, 7 ATK, 0 DEF.
- Weapons: his canes (Ashwood Cane, Shepherd's Crook, moveset
cane: two quick weak raps, x0.7 and x0.8; two raps in the air; a short bouncing plunge) drop while a Tobias plays; he can also hold the Quick Dagger. A new save (or empty hands) gets a Common Ashwood Cane. A hit marks the enemy (Player.familiar_target): all his familiars go for it while it lives and is within 20 tiles of him. Armour: only Leather Coat, Chain Vest and Hunter's Garb (Items.TOBIAS_ARMOR; a heavier piece from a shared save goes to the bag); every charm. - Subweapon button (his familiars; at most two out, calling a third sends the oldest away; they take no damage and leave when their time ends or he goes down):
- Hound (alone, 12 MP, 1 s): a spectral wolf (the wolf enemy's sheet on a cyan ramp) for 12 s. It runs on the ground to the marked enemy or the nearest within 10 tiles of him, jumps onto platforms and drops off them, bites every 0.6 s; with nothing to hunt it keeps to his heel. Mastered: two wolves for one call.
- Moth Swarm (+ forward, 10 MP, 1 s): flies to an enemy (up to 3 s of looking) and sticks to it for 5 s: 40% slower, stung every 0.5 s. Mastered: when its host dies it moves on to the nearest enemy within 6 tiles.
- Carrion Crow (+ up, 8 MP, 0.8 s): dives at a flying enemy within 10 tiles (three dives); with none, fetches the nearest loot or heart within 10 tiles to him (taken as if he had walked over it; dropped at his feet if he cannot take it). Never a teammate's drop. Mastered: subweapons too. With nothing to dive at or fetch, nothing is spent.
- Den (+ down, on the ground, 0 MP, 1 s): he hides in his pack while Down stays held: half damage, no knockback, no walking, jumping or swinging; the familiars keep within 3 tiles of him. Mastered: every familiar bites whatever touches him.
- Bites and stings are light hits (
Enemy.familiar_hit: damage, crits, mastery and loot, but no hit-stop, knockback or stun, so a pack does not lock an enemy in place); a crow's dive and a Den bite are full hits. Damage: 8 a bite, 3 a sting, 14 a dive, plus half the gear attack, the track's mastery bonus and sub_power. The swarm's slow isEnemy.swarm(the enemy moves at 60% of the speed its AI chose). - Network: the familiars are host-simulated and interpolated on clients
like enemies, but cheaper: spawned through the level's ProjectileSpawner
(
Level.spawn_familiar, built byFamiliar.buildon every peer), one synced property (net_state, aVector4: x, y, host frame, action and facing) 20 times a second, drawn 0.15 s behind. The Den is simulated and predicted like Brunhal's guard (Player.denning). - Mastery: the melee tracks plus Hound, Moth Swarm, Carrion Crow (each trained by its own hits) and Den (by hits taken in it).
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:
- builds one animation per Blender action with the atlas fps and loop flag
(
scripts/sprite_sheet.gd, cached per atlas); - wraps both sheets in one
CanvasTexture, so anyPointLight2DorDirectionalLight2Dshades it through the normal map,flip_hincluded; - offsets the frame so the node's origin is the character's feet (Blender ground z = 0), which matches the player's collision shape origin;
- adds Aliases (e.g.
run->walk) and copies of Fallback Animation for any Required Animations the sheet lacks, soplay()never asks for a missing name.
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
- Prediction covers movement only. Hits, interactions and pickups still wait for the host, so they land one round trip after the swing on a client.
- The host does not rewind for lag: a client's attack is judged where the target is on the host when the input arrives.
- A client that stops sending input (stall, packet loss burst) freezes on the host until input resumes; a backlog over 16 ticks is dropped.
- Hit-stop starts on the host when the swing connects but on the attacking client only when the confirmation arrives, one round trip later. Standing ground attacks do not move, so this is invisible; attacks while moving or airborne get a small correction.
- Remote-player smoothing is checked by the autotest only for its end state;
it has not been watched by eye under
--lagyet. - No game over: a team wipe (every player down, ghosts included) respawns
everyone and resets the boss. The start room has a training dummy
(
scenes/training_dummy.tscn): an Area2D on the interactables layer with a host-owned, replicatedhpthat takes the attacker's swing damage and refills 1.5 s after reaching 0. - Steam lobbies and invitations are wired (
scripts/steam_lobby.gd) but only run with the GodotSteam extension installed; without it the game ignores them. The game itself still travels over ENet (an address stored in the lobby), not Steam's relay. See "Joining a game". - Ghosts and the new enemies use the existing frames (a ghost is the fall frame, pale and see-through; the Sentinel, leaper and wisp are block-outs).
- Saved gear is trusted within limits: the host clamps it to what could have dropped, but a player can still edit their file to give themselves any legal item. Fine for co-op among friends; not for anything competitive.
- The swing animations are a scripted pass on a simple rig: one bone per arm, no elbow. They read clearly at game scale, but a human animator (and an elbow bone) would do better. Weapons are modelled shapes with bevelled edges, not sculpted.
- Dropped items on the floor are lost if the chapter restarts before anyone picks them up. Only bag items can be dropped or destroyed; equip something else first to get rid of a worn piece.
- Running a two-peer test straight after editing a script can fail once,
while both Godot processes rebuild the script cache together. Run
--importfirst, or run the test again. A full bag leaves new pickups on the ground. - Air attacks are predicted exactly at 0 ms. Under 150-300 ms lag, the few ticks right after a down-strike bounce or smash can be off by about 5-20 px until the host's next states blend it away; hit timing is not affected.
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.
- It walks at 55 px/s and turns when it meets a wall or when a short ray finds no ground ahead.
- Contact damage. While walking (not stunned or dying), a
Contactarea hurts any player it touches for 15 throughPlayer.take_damage. - Taking hits. A child
Hurtbox(scripts/hurtbox.gd) sits on the interactables layer in grouphurtableand forwardshit()totake_hit(). A hit costsPlayer.ATTACK_DAMAGEand freezes the husk for the hit-stop. Then it is thrown away from the attacker (KNOCKBACK, 240 px/s sideways, 200 px/s up), stunned for 0.4 s, and turned to face the attacker. 30 HP: three hits. - Death. At 0 HP the throw is bigger, the husk falls for 0.6 s, fades
out and stops.
aliveis replicated, so late joiners see it gone. - Replication.
Syncsendsnet_facing,hp,aliveand a frame-stampednet_state. Clients draw it throughNetInterpolator, the same buffer remote players use, so knockback arcs look smooth. - Physics layer 6 "enemies": husks collide with the world and one-way platforms but not with players.
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.
- Stationary. It faces the nearest standing player within 320 px that it can see; walls block sight, one-way platforms do not.
- Telegraph. When its cooldown is up it winds up for 0.6 s
(
net_action = WINDUP): it swells, its mouth brightens and a green glow lights the floor, on every peer. The aim is locked when the wind-up starts, so moving dodges the shot. - Shot. A
Projectileat 200 px/s, 15 damage throughPlayer.take_damage(knockback, client replay), then 1.8 s of cooldown. Shots pass through one-way platforms and downed players, stop at walls, and expire after 4 s. - Counterplay. Any hit during the wind-up cancels the shot (0.8 s cooldown). 20 HP, heavy: small knockback, 0.25 s stun.
- Projectiles are spawned by
Level/ProjectileSpawnerwith their start position and velocity. Every peer flies them locally (nothing is synced in flight), and only the host checks hits. Freeing one on the host despawns it everywhere. Clients see a shot about half a round trip late, starting from the spitter as drawn (100 ms behind).
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.
- Walks at 40 px/s like a husk (turns at walls and ledges) but does no contact damage.
- Telegraph. When a standing player it can see is within 72 px in front
of it and about level with it, it stops and winds up for 0.5 s. The club
rises and the brute turns red under a red glow, on every peer through
net_action. - Swing. For 0.12 s a
Swingarea in front deals 25 damage, once per player per swing, with 1.6 times the normal knockback (Player.take_damage(..., kb_scale)). - Recovery. 0.7 s slumped with the club on the floor, then 0.5 s of walking before it can swing again.
- Armour. Through wind-up and swing,
_armored()is true: hits deal damage and hit-stop but no knockback or stun, so it cannot be interrupted. Get out of reach (backdash) and punish the recovery. 50 HP.
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.
- Waking. Dormant until a standing player is in the arena
(x >
ARENA_LEFT, 3904). Then every HUD shows a boss bar with its name. - Between attacks it walks toward the nearest player (stopping 110 px
away) for
IDLE_TIME(1.1 s), then picks an attack. A close target (under 150 px) gets mostly slams; a far one gets charges and volleys. - Slam. Both arms rise for 0.7 s with an orange glow. Then a blow in front (25 damage, heavy knockback) and two shockwaves along the floor (20 damage, 260 px/s), one each way. Jump them.
- Charge. It crouches for 0.6 s, flickering red. Then it rushes at
420 px/s until it meets a wall or the arena's left edge, hitting anyone it
passes once (30 damage). Then it is EXPOSED for 1.2 s: slumped, pulsing
yellow, and taking double damage (
_damage_taken). - Volley. Its chest glows green for 0.5 s, then it fires a fan of shots at where the target stood when the glow began: three shots, five in phase 2.
- Phase 2 at half HP: darker red, "(enraged)" on the bar, and telegraphs
and pauses are 25% shorter. Each peer derives the phase from the replicated
hp, so nothing extra is synced. - Leash and reset. With no standing player in the arena for 4 s, or on a
team wipe, it returns home at full HP, dormant (
reset()). - Exit crystal. Its death does not end the chapter at once. An
ExitCrystalappears 180 px from where it fell, toward the middle of the arena (kept inside it), with a banner: "The Warden has fallen. Gather the loot, then touch the crystal to leave." The first standing player to touch it ends the chapter as CHAPTER CLEAR (see "Chapter end, results and restart"). The clock keeps running until then. The crystal's position andactiveflag are replicated, and a restart hides it. - Test knobs.
force_nextpicks the next attack andholdstops it from choosing on its own. Only the test scenarios use them.
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.
- Hangs from the ceiling until a standing player is within 300 px in line of sight, then swoops at their chest in a wavy line (170 px/s).
- Touching a player deals 10 damage and makes it pull away up and back for 0.9 s; then it swoops again. A swoop that misses for 2.6 s also pulls away. With nobody within 520 px it flies home and hangs up again.
- 12 HP, big knockback; a hit makes it pull away once the stun ends.
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.
- Slam. The hand rises over a player (a dark shadow flashes on the floor under it) and comes straight down through every ledge (30 damage). Step aside. Phase 2 slams twice.
- Sweep. The hand drops to the floor, flashes red, then sweeps across the whole pit (25 damage): be on a ledge or jump it. Afterwards it is strained for 1.8 s: the core flashes yellow, the head sags, and head hits do 1.5x damage.
- Rocks. It roars and rocks fall from the ceiling all over the pit (6, 9 in phase 2), spread out with gaps.
- Beam (phase 2 only). The eyes glow cyan, then a fan of shots flies at the highest player, so the top ledge is not safe either.
- Wakes when a standing player walks 320 px into the pit; phase 2 at half
HP; a team wipe resets it; its death opens the exit crystal like the
Warden's. Every peer poses the hand from
net_action,net_target_xand the time since the action began, the same way the host does.
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).
- Asleep until a standing player it can see is within 420 px and about level; alone for 6 s it goes back to sleep.
- Shield. Always armoured (no knockback or stun). A hit from the side it faces while the shield is up is blocked: 40 % of the damage, a clang, and the guard meter rises. Six blocked hits break the guard: 1.8 s staggered, shield down, full damage, knockback and stun. Hits from behind land in full at any time, and during the cleave the shield is down too.
- Attacks by distance to its target: a shield bash within 84 px
(0.45 s wind-up, 18 damage, big knockback: it makes room), an overhead
cleave within 160 px (0.7 s wind-up, 30 damage, the shield drops while
it winds up), a fan of three bone shards further away (0.5 s wind-up,
12 each,
Projectile); otherwise it walks toward you at 45 px/s. - Enraged under 30 % HP: wind-ups and pauses are 0.7 times as long. 220 HP.
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).
- Rampart Husk (
rampart.gd): a husk behind a door.take_hitis overridden like the Sentinel's: a hit from the side it faces is blocked outright (a clang RPC, no damage number) and counts on a guard meter;GUARD_MAX(4) blocked hits break it (STAGGER1.4 s, open). Hits from behind land in full. A player right in front gets aSHOVE(0.4 s wind-up, 14, 1.9x knockback); it is armoured through the shove. - Cinder Lobber (
lobber.gd): the spitter's cousin. It lobs aProjectile"lob" that flies an arc:Level.spawn_projectiletakes anextradictionary andgravity(px/s^2) bends the shot the same way on every peer (Projectile.shot_gravity;Area2Dalready ownsgravity). The launch angle is fixed (62 degrees) and the speed solved from the range, so the ember lands where the player stood when the wind-up began. Line of sight is checked from well above its head so a ledge does not hide you. - Veil Shade (
shade.gd): a flier withcollision_mask0 (through walls) whoseHurtboxshape is disabled on every peer while it drifts (is_solid()), so nothing can hit it until it winds up a claw. A hit during the wind-up breaks the claw and leaves it solid (LINGER). - Ceiling Dripper (
dripper.gd): a flier that casts a ray up on its first tick and hangs from the ceiling it finds (stage anchor "top"), creeps along it while there is ceiling ahead, and drops a "drip" projectile under gravity. Its damage numbers and bar sit below it. - Powder Thrall (
thrall.gd):_damage_takenturns the killing blow into 1 HP left andFUSE(armoured, nothing more gets through); after 1.3 s it bursts (28 within 96 px) and dies the wisp's way (hp 0 through a short hit-stop, so loot and the fade follow). - Bone Chanter (
chanter.gd): backs away from close players (stopping at ledges and walls), and every 2.6 s heals the other hurt, living, non-boss enemies within 260 px by 12 (hpset directly; their bars follow through replication). A hit cancels the chant. With nobody to heal it flicks a bone. - Rolling Grinder (
grinder.gd): sleeps, winds up 1.3 s (open), rolls 2.6 s at 265 px/s bouncing off walls (_wall_aheadflips it), armoured and taking 40 % damage, hurting on touch once per 0.6 s per player. - Lantern Archer (
archer.gd): replicatesnet_aim(an extra Sync property beforenet_state) so every peer draws the beam (aLine2Don the root, not the mirrored body) following the target, then white and still for the lock, then a fast "bolt" projectile along it. - Gilded Mimic (
mimic.gd): drawn as a chest, waits until a player is within 56 px or hits it, then a lid-open wind-up and a lunge, then hops after the nearest player. Loot sourcemimicpays like a chest (one Uncommon or better, 15-30 gold). - Crypt Burrower (
burrower.gd): buried (hurtbox off, only a mound drawn) it digs under the nearest player on its floor, swells, erupts straight up (20, 1.5x knockback) and stays surfaced 1.7 s (open) before diving again. - Iron Jailer (
jailer.gd, groupminiboss, 300 HP): stomp (22 in front first, then two floor waves from 72 px out), the double lash (a chain segment swept over and down, then back low:TowerClock.segment_hits_rectagainstbody_rect, one hit per player per swing), and the hook: a segment that flies to the lockednet_aimand back; a caught player takes 10 with the knockback source on their far side, soPlayer.take_damagethrows them toward the Jailer (2.3x), and a lash follows. A hook that catches nobody leaves itRECOILing 1.5 s (not armoured, hits x1.5); after a stomp itRESTs 1 s (not armoured). Every peer draws the chain fromnet_action,net_aimand the time since the action began (chain_tip). - Glass Cantor (
cantor.gd, groupminiboss, 260 HP, flying): hovers 150 px over the floor it was placed on, 90 px to one side of its target. Wail (a ring of 8 "glass" shards; 12 enraged), shriek (a fan of three), dive (to the locked x; 20 around the landing) thenGROUNDED1.8 s (not armoured, hits x1.5), then it rises. Airborne it is armoured: jump attacks land but never stagger it. - The Pale Huntsman (
huntsman.gd, groupboss, 900 HP, stagehuntsman_glade): three phases from hp (phase(), so the HUD's music layers follow). Mounted: charge (32,STAGGER1.2 s on the wall, x1.5), leap (a ballistic jump tonet_aim; the landing does 26 under it plus two waves), volley ("spear" projectiles from the ceiling under gravity, 5; two marks show the spread while it winds up). Below two thirds itDISMOUNTs (1.6 s, x1.5; the Mount node hides) and fights on foot: sweep (a spear segment, 24 then 18), throw (a spear out at 440 px/s for 0.85 s, then one spawned from where it got to flying back), dash (620 px/s, 20). Below one third it howls on entering (10 shots + waves) and the volley has 7 spears. Timings scale byPHASE_SCALE. - The Glass Saint (
saint.gd, groupboss, 820 HP, flying, stagesaint_nave): its origin is its head and it rises tohover_y()(170 px + its 80 px body over the floor it was placed on) in_ready. Pillars (three x positions fromnet_aim, marked as faint lines during the wind-up, then thick columns for 0.5 s: a player within 22 px of one burns), halo (a ring of 10 glass; two rings interleaved in phase 3), shatter (glass from the ceiling under gravity), descent (it falls to the locked x: 22 around the landing and two waves, thenKNEEL2.2 s, hits x1.6; everyDESCENT_EVERY[phase]attacks), gaze (phase 2+: a beam from the saint to a floor point that sweeps from the arena edge onnet_sideto under it,segment_hits_rect), mirror (phase 3: fades, blinks to another third, fades in and halos). Entering a phase it sings (CHOIR: a pause and a ring). On death it drops to the floor (_drop_lootoverride) so its loot and the exit crystal are in reach.
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.
- An item is a plain Dictionary, so it travels unchanged through spawn
data, RPCs and synchronizers:
{uid, base, rarity, stats}, plusaffixesandsetwith rich loot (below). - Bases. Nine, three per slot (weapon, armour, charm), each with a stat range and sometimes a tradeoff. A War Hammer hits hard but slows you; a Quick Dagger hits lighter but speeds you up; an Iron Plate gives defence but slows you.
- Stats.
attackis added to every swing.defenseis taken off every hit, but at least 1 damage always gets through.max_hpadds to max HP.speedchanges run speed (+0.05 = +5%, never below 50%). - Rarity. Common, Uncommon, Rare or Epic. It multiplies an item's positive stats by 1.0 / 1.3 / 1.7 / 2.2 (tradeoffs stay as they are) and colours its name, its gem on the floor and its popup.
- Loot tables (
Items.SOURCES). Each source has a drop chance, an item count, a rarity floor and a luck factor that shifts rolls upward:
| 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 |
- Choosing the chain. Pressing attack picks the chain: on the ground
the ground combo; in the air the
airchain, or thedownmove while Down is held (sent as theattack_downaction). - Aerial swings slow your fall while they swing (
hang), so combos stay up. Landing ends them. - Plunges. A step with
plungedives straight down at that speed, with the hit area under your feet. You can steer at 30% of run speed. - Bounces. With
bounce, a hit springs you back up, ends the attack and gives the air jump back, so you can pogo from enemy to enemy. - Heavy plunges continue to the floor and then start their
autolanding step. - Online. The host decides every hit. It tells the attacking client
which tick the hit-stop (and any bounce) began on (
confirm_hit). The client rewinds to its predicted state for that tick, applies the change, and replays the ticks since with the same inputs (direction and actions). Hurt knockback uses the same replay (_replay_from). The position, velocity, attack and floor state of every predicted tick is kept for this (_snap). - Saved between sessions (
scripts/gear_save.gd). Each player's equipped items and bag live inuser://gear_<profile>.jsonon their own machine (on Windows, under%APPDATA%/Godot/app_userdata/<project>/). - Saving: whenever the host changes a player's gear, it sends that player
their new gear (
_store_gear) and their game rewrites the file. - Loading: when a player's character appears (hosting or joining), their
game sends its save to the host once (
submit_saved_gear). The host runs every item throughItems.sanitize: unknown items and rarities are dropped, unknown stats removed, each stat clamped to what that item and rarity can roll, wrong slots and bag overflow discarded, fresh uids given. It then applies the gear and replies so the cleaned version is written back. - Profiles:
--profile NAMEpicks the file (defaultdefault). Two game windows on one machine must use different profiles or they share one save. In the editor, Debug > Customize Run Instances can give each instance its own launch arguments. - Test scenarios never read or write a real save.
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:
- Secondary stats.
crit(chance that a hit deals x1.5, "CRIT!" pops over the enemy),lifesteal(share of the damage you deal to enemies that heals you, at least 1 HP a hit; not on the dummy),mp_regen(MP/s on top of the base 1),sub_power(subweapon damage share) andluck(multiplies a drop source's luck when you land the killing blow or open the chest, so rarer rolls).Player.gear_statsums them like the others. - Affixes. Uncommon, Rare and Epic items roll one, two or three
(
Items.AFFIXES, at most two prefixes and two suffixes), each fitting its slot, with values that grow with rarity. The item is named after its first prefix and first suffix: "Epic Vampiric Greatsword of Precision". Stored as"affixes": [{"id": "vampiric", "v": 0.04}, ...]; always read an item's stats throughItems.stats_of(item), which adds them. - Six more bases (armour and charms, flagged
rich): Hunter's Garb, Bastion Mail, Hawkeye Charm (crit), Leech Fang (lifesteal), Mana Prism (MP/s) and Lucky Coin (luck). - Sets (
Items.SETS). Two sets of three named Epic pieces with one affix each, shown in their own colour with a SET badge: Midnight Regalia (Tolling Hammer, Gearwright Vest, Pendulum Charm; 2 worn: +8% crit, 3: +6 ATK and +20% subweapon damage) and Warden's Vigil (Vigil Blade, Vigil Plate, Vigil Locket; 2: +3 DEF, 3: +30 HP and +5% lifesteal). A boss drop is a set piece 25% of the time, a chest drop 3%. - Saving. Affixes and the set tag are saved with the item; the host's
Items.sanitizedrops unknown, repeated, wrong-slot or surplus affixes, clamps their values to what that rarity can roll, and keeps a set tag only on that set's own base. - Gear screen. Item cards and the details card show every stat; the comparison covers all of them, a set piece shows its set, how many are worn and both bonuses, and the STATS block adds a line of secondary stats (with the same before/after colours).
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
- Name. The start screen has a Name field (at most 16 characters, kept in
the settings file). The host cleans it (
Net.clean_name) and every peer sees it above the player, in the party list and on the results screen; without one a player is "P1", "P2"... - Handshake. A client that connects is not in the game yet: it sends
Main._hellowith its version (Net.VERSION) and name. The host sends the stage at once (the level's synchronizers need it), but only then sends the map and spawns its player. It refuses, with the reason, a client whose version differs ("Version mismatch: the host runs 0.6.0, you have 0.5.2...") or a full game (Net.MAX_PLAYERS, 6), and drops a peer that never says hello withinNet.HELLO_TIMEOUT(5 s; an older build). BumpNet.VERSIONwhenever anything that crosses the network changes; it is shown under the start screen. - Errors. A refused client, one whose host left or whose connection
dropped is sent back to the start screen with the reason
(
Net.last_message). A host that cannot be reached withinNet.JOIN_TIMEOUT(10 s) gets "Could not reach the host at ..." with what to check (address, host running, port open or a VPN). Hosting on a port already in use says so. -
The Forge Hall is the lobby. In the hub the HUD lists the party (who hosts, who is ready). The chapter board opens the chapters with the party beside them: a client's Enter / click toggles its ready state (
Player.ask_ready, host-checked); the host picks the chapter, and if someone is not ready the first press only warns and names them, a second press within 3 s starts anyway. Back in the hub everyone but the host is not ready again. -
Quick chat and emotes. T (L3 on a pad) opens a list of eight quick messages ("Help!", "Follow me", "Wait here", "Go!", "Thanks", "Sorry", "Boss ahead", "Loot here") and four emotes (wave, cheer, laugh, point),
Emotes.LIST; 1-9 or Up/Down + Enter sends, Esc closes, the character stands still meanwhile. Only the index crosses the network (Player.request_say-> host ->_show_sayto everyone): the host checks the sender and allows one per player per 0.6 s. A message shows as a bubble over the character for 2.5 s and a line in the chat log at the bottom left (the last six, each fading after 9 s); an emote shows the bubble and a little hop, no line. The log also reports Phials of Return and Last Rites. Works in the hub and in a chapter. - Steam lobbies and invitations (
scripts/steam_lobby.gd, autoloadSteamLobby) come alive when the GodotSteam extension is installed and Steam is running (Engine.has_singleton("Steam")); otherwise every call is a no-op and the game is unchanged. With it: hosting opens a friends-only lobby carrying the version, name, host Steam id and address; the options menu gets "Invite Steam friends" (the overlay's invite dialog); accepting an invite (the overlay, or Steam starting the game with+connect_lobby <id>) joins the lobby, checks the version and joins the game at the stored address (a LAN or VPN address: the game itself still travels over ENet, so Steam's relay is the next step, with GodotSteam'sSteamMultiplayerPeerinNet.host/Net.join).SteamLobby.APP_IDis Valve's test app (480) until the game has its own. Not covered by the headless tests.
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).
- Depuis GitHub (rien à installer) : à chaque push sur
main, la CI construit le build Windows. Ouvre le dépôt sur GitHub > onglet Actionsle dernier run « CI » réussi sur
main> en bas, Artifacts > CastleCrawler-windows. Tu télécharges un zip (gardé 30 jours). - Ou en local : installe une fois les modèles d'export Windows de Godot
4.7.1 (dans l'éditeur : Editor > Manage Export Templates > Download and
Install), puis depuis Git Bash à la racine du dépôt :
deploy/desktop/build_windows.sh. Le zip sort dansbuild/CastleCrawler-windows-<version>.zip.
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)
- Toi : crée un compte sur https://tailscale.com et installe Tailscale sur ton PC (icône « Connected » dans la barre des tâches).
- Invite tes amis : console Tailscale (https://login.tailscale.com) > Users > Invite users, ou Share ta machine avec eux.
- Chaque ami installe Tailscale, se connecte et accepte l'invitation.
- 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
- 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). - Tes amis : lancent
CastleCrawler.exe, écrivent leur nom, mettent ton adresse100.x.x.xdans 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. - 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.
- 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.
- À 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 :
- 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). - Les amis ouvrent la même adresse, écrivent leur nom, tapent le code dans Code et cliquent Join.
- Ensuite, tout est pareil qu'en version Windows : groupe, READY au tableau, chapitres.
À savoir :
- Joueurs web et joueurs Windows ne peuvent pas jouer ensemble (la version Windows n'a pas WebRTC). Choisissez l'une ou l'autre pour la soirée.
- Gardez l'onglet du jeu au premier plan : le navigateur met en pause un onglet caché, et la partie se fige pour tout le monde si c'est l'hôte.
- Certains réseaux très stricts (entreprise, école, certains Wi-Fi publics) bloquent les connexions directes entre navigateurs : « Could not connect to the host's game ». Il faudrait alors un serveur relais (TURN), pas encore installé.
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.
- Gold (
Player.gold): host-owned, replicated by GearSync, kept in each player's gear save (version 3;GearSave.migratereads version 2 files as no gold) and clamped toPlayer.MAX_GOLDwhen a save is taken. Earned when a chapter ends (Player.grant_chapter_gold: 100 for a clear, 20 when time runs out, +2 a kill; on the results row asgold), from coins and by salvaging. - Gold coins (
Items.roll_gold,Items.GOLD_DROPS). Enemies and chests drop coins: a husk 2-5 gold 60% of the time, a spitter 3-6 (70%), a bat 1-3 (50%), a brute 6-12 (90%), a boss 60-90 and a chest 15-30 every time; the killer's or opener's luck raises the amount, and a big drop splits into several coins of at most 25. The first player to touch them takes them (Player.pick_gold: a gold "+N" rises from them); they blink and go after 30 s. They count in the gold the results screen shows. Off for the older headless tests (Items.gold_drops), whose loot counts predate them;--goldtest(host + client, in the CI runner) checks the rolls, a chest's coins, picking them up on the client and the results row. - The forge (stage type
forge,scripts/forge.gd). Use it and your gear screen opens on a FORGE tab, which only shows while you stand withinPlayer.FORGE_RANGEof a forge. Pick a worn piece or a bag item, then: - Upgrade (Enter, or the button): +1 forge level, up to +5; each level
raises the item's positive base stats by 10% (
Items.UPGRADE_STEP; tradeoffs stay). Cost20 x (level + 1) x (rarity + 1)gold. The name shows it: "Rare Keen Long Spear +2". - Reforge (E): new random affixes, as many as before (a set piece keeps
one), for
30 x (rarity + 1). Commons have none. - Salvage (Delete twice): melts a bag item down for 5 / 15 / 40 / 100 gold by rarity (150 for a set piece), +10 a forge level.
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
- Ending. The host ends the chapter when a player touches the exit
crystal that appears once the boss is dead (
CLEAR), or when the clock reaches 0 (TIME UP).Main.chapter_over,chapter_resultandchapter_clearedare replicated by WorldSync. - Freeze. While the chapter is over, player input is ignored, enemies
stop simulating, nobody takes damage, and flying shots are removed
(
Player.chapter_frozen()). - Results. The host sends every peer one row per player over a reliable
RPC (
Main._receive_results): loot, kills, damage dealt to enemies, times downed, and revives given. The host counts these as play happens (Enemy.take_hitcredits the attacker;take_damageandrevivecount downs and revives), plus the gold that chapter paid them (see the hub), what they picked up (Player.chapter_finds: name, rarity, set or subweapon) and the masteries that ranked up (Player.chapter_ranks). - Results screen (
scripts/results_screen.gd, classResultsScreen, drawn in code): CHAPTER CLEAR in gold or TIME UP in red, the stage and the time left (or which boss still stands), and one card per player (up to three a row): kills, damage, downs, revives, gold earned, the finds in their rarity colours (set pieces in the set colour, subweapons in violet) and the mastery rank-ups. With company, the best at kills, damage and revives get a badge (Most kills, Most damage, Lifesaver). The host has two choices, keyboard or mouse: play again (R, below) or back to the Forge Hall (use / E:Main.go_to_stage("hub")); clients see that the host decides. Gold, gear and mastery are kept either way. - Restart. The host presses R (
restartaction) or clicks Play again. The scene is not reloaded, because that breaks the spawner and synchronizer paths. Instead,Main.restart_chapter()puts everything back in place: - the level (
Level.reset_world(): lever off and so door shut, chest closed, loot and shots removed, dummy healed, every enemy back at its placed spot alive with full HP and its AI reset throughEnemy.revive_full()/_reset_state()); - every player (
Player.reset_for_restart(): spawn point, full HP, no loot, stats zeroed); - the clock.
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
- HP. 100 per player, replicated (
hp), shown in the HUD and as a small bar over a player who is hurt or downed. - Taking damage.
Player.take_damage(amount, from_x)runs on the host. Husks call it on touch (15 damage). The player is thrown away from the source (HURT_KNOCKBACK), loses control for 0.3 s (HURT_STUN), and is invulnerable and blinking for 1 s (INVULN_TIME). The blink is driven by a replicated counter,blink_count, so every peer shows it. - Prediction. The host sends
apply_hurt(kb, seq, downed)to the hurt player's client. Being hurt removes control, so the client rewinds to its recorded position after input tickseqand replays the ticks it already ran with the knockback. It lands within a few px of the host without waiting a round trip. - Downed. At 0 HP the player is
downed(replicated): no control, lying on the floor dimmed (no downed frames yet, the sprite is rotated), and ignored by damage. A teammate pressing interact within 56 px revives them at 50 HP with a fresh invulnerability window. Reviving takes priority over levers and chests. - Ghost. Downed for 2 s (
GHOST_DELAY) with at least one other player in the game, you rise as a ghost (ghost, replicated; the name reads "(ghost)", the sprite is pale blue, see-through and bobbing): the stick flies you in every direction at 190 px/s, through one-way platforms but not walls, with no gravity. A ghost still counts as downed: no attacks, no pickups, no damage, and enemies ignore it. Fly to a teammate: their interact next to you brings you back on your feet where the ghost is. Solo there is no ghost; the team wipe respawns you after 3 s as before. - Phial of Return. A pickup (
{"kind": "revive"}; 30 % from chests, 6 % from brutes, 60 % from the mini-boss, always from a boss, on its own random generator) carried up to two at a time (revive_phials, replicated, chapter-only; the HUD's stats line shows them). Down + interact drinks one: the nearest fallen teammate, anywhere in the chapter, body or ghost, gets up where they are at 50 HP, and the chat log says who. With nobody down the phial is kept. A teammate right next to you is revived the normal way first, without spending it. - Last Rite (Aurel's seventh scroll,
last_rite, 25 MP, 4 s cooldown): raises every fallen teammate within 8 tiles at half HP; rank 5 doubles the reach, rank 9 raises them at full HP. With nobody in reach the MP is refunded. It drops like the other scrolls. - Team wipe. If every player is downed, the host waits 3 s
(
WIPE_RESPAWN_DELAYinmain.gd) and respawns everyone at the spawn points with full HP. The chapter timer keeps running. - Teleports and prediction. Any host move of over 96 px (respawn, test moves) puts the predicted body exactly where the host says and stops it, instead of shifting it by the error, which could push it into the floor.
Hit feel
-
Dev damage multiplier.
Player.dev_damage_multiplier(top ofplayer.gd) is 1. Raise it locally to test a boss faster (at 5 a swing deals 50 instead ofATTACK_DAMAGE, 10) but do not commit it. The test scenarios force 1, so their expected numbers are unaffected. -
Hit-stop. When a swing hits anything in group
hurtable(the dummy; not the lever or chest), the attacker freezes forPlayer.HITSTOP_TIME(80 ms): no movement, timers paused, sprite paused, and the attacker's own camera shakes. Buttons pressed during the freeze run right after it. The host freezes its copy at once and sendsconfirm_hitto the attacking client so its prediction freezes too. - Knockback. The dummy shakes during the hit-stop, then leans 0.45 rad
away from the blow and springs back over 0.6 s. Direction travels as
last_hit_dir, replicated beforehits, whose setter plays the reaction on every peer. Constants are at the top oftraining_dummy.gd. - Verified on Godot 4.7.1 (the binary available on this machine); the project
targets 4.6 and uses nothing newer than 4.3 APIs (
TileMapLayer).