Short cycle, one change: the enemies know about each other now.
v0.2.9 made missions procedural — a fresh room graph every time you leave the hub. This build doesn’t touch that. It’s about what happens once you’re in a room with three or four enemies. Until now each of them was a private automaton on its own attack cycle: four next to you meant four independent 1.1-second swing timers firing whenever they came up, and a group fight was a pile-on, not something you could read. v0.3.1 puts a referee in the middle of it.
(There’s also a v0.3.0 in between, tagged separately — a pure refactor, no gameplay change. More on why below.)
An attack referee (EnemyDirector)
The rule is one line: an enemy needs a token to swing, or to close in. There are two tokens (max_attackers, default 2). Everyone else holds a ring at standoff distance and waits.
The token rotates. An enemy claims it on the way in, swings, and drops it the moment the swing ends — so the floor passes around instead of belonging to the same two for the whole fight. A holder that can’t reach you (a ledge, a wall, an ally in the way) loses it on a 3-second lease so it can’t jam the queue, and the enemy that just yielded can’t re-grab it for 0.8 s.
So a crowd reads as a circle you work rather than a heap: two enemies pressing, the third and fourth circling for their turn. Whether two-at-once is right, or whether it should be one — duels with an audience — is the main thing I want feedback on. It’s a knob, not a decision I want to make from the code.
This is also the piece the real boss needs. Legion (Koncept §8) doesn’t attack at all — he only summons — so the whole fight is the crowd. Without a referee he’s just a spawner flooding the screen. That’s v0.3.2.
Honest telegraphs
The blocking enemy used to read the player’s internal “am I attacking” flag — which flips before anything shows on screen. You couldn’t out-read something that wasn’t looking at the screen either.
Now it reacts to what’s visible: a started wind-up, or a combo already in progress from the second hit on. Which means the first hit always lands — the block is a response to the fact that you’re already swinging, not a prediction. There’s a reaction window now (~0.2 s) instead of an instant guard, and per-type knobs for reaction delay and block chance, so a type can be made readable without making it blind.
The dead zone in your own attack
A bug report — “the normal attack feels unresponsive” — turned out to be real and specific. Holding the attack button between the tap window and a full charge — roughly a tenth of a second to half a second, very easy to hit by accident — routed to “cancel” and produced no attack at all. Same for releasing a ready charge with no stamina. Pressing attack and getting silence is the worst thing an attack button can do.
New rule: every release does something. A full charge with stamina gives the charged hit; anything else gives a normal one. The tap window went from 0.12 s to 0.18 s. (This is pure gameplay logic now — a plain function that a test walks across the whole range of hold durations, 0.02 s at a time.)
Spacing, kiting, ledges
- The queue keeps its distance. Tokenless enemies hold a ring with a narrow band, so they approach and back off instead of jittering across a threshold. Added crowd spacing on top: enemies were all walking to the same point from the same side and standing shoulder to shoulder — one blob with several health bars. Now whoever’s crowding the closer ally gives ground, and the line sorts itself by distance.
- The archer kites. It doesn’t ask for a token while it has a shot — so it doesn’t starve the melee queue holding a token it won’t spend, and it drops to its ring, i.e. it backs away as you close. Its ring is ~110 px: inside its own range, outside a katana’s. It also needs a clear line of fire now — a raycast from the muzzle to the target, and it holds the shot if the first thing the ray hits is a wall or one of its own allies. Cornered, it still swings.
- Enemies stop at ledges. The chase used to look only at the direction to the player, so enemies walked off into the void after you. Now it probes for ground just ahead; the chase direction zeroes at an edge, and a patrol turns around at a wall or a drop.
Enemy stats are data now
Everything that makes one enemy fight differently from another — detection and attack ranges, speeds, the three attack timings, the whole defence profile — is an exported field on the base enemy now, not a subclass override. A new enemy type should be a scene with different numbers, not a new class. This is the groundwork for elites (§4.2): tougher, faster, longer-reaching versions of what already exists. What stays code is behaviour that’s genuinely different — a second attack mode (the archer’s shot), and Legion not attacking at all. Numbers are data; behaviour is a hook.
Why there’s a v0.3.0 you can’t see
The enemy code got split in two — locomotion and combat — before any of the above. That restructure went out as its own tagged build, v0.3.0, with one correctness criterion: it plays exactly the same. That’s the opposite of this build’s criterion (“it plays better”), and keeping them in separate versions means a regression in v0.3.1 can’t be mistaken for an intended change. v0.3.0 also brought the project from zero automated tests to 15 headless suites, each checked that it fails when the thing it guards is broken — which is how the referee’s one real bug got caught (a deadlock where the token could only be claimed from melee range, but a tokenless enemy was held out at the standoff ring — so once the referee shipped, no aware enemy ever attacked again; every existing test stayed green because each one either spawned the enemy already in range or only checked that it started walking).
Controls
Unchanged from v0.2.9.
| Action | Keyboard | Pad (Xbox) |
|---|---|---|
| Move | ← / → | left stick / d-pad |
| Jump (double) | Space / Enter | A |
| Melee (3-hit combo) | A — tap | RB |
| Charged attack | A — hold ≥ ~0.5 s | RB — hold |
| Third-hit variant | hold ↓ heavy · nothing = fast · ↑ = air launcher | same |
| Block / parry | D — hold / tap just before the hit | LT |
| Dodge (dash, i-frames) | Shift | LB |
| Shoot (8-way) | S | RT |
| Reload | R (auto on empty) | Y |
| Interact / throw-out | E | X |
| Pause / menu | Esc | Start |
| Overview (relic + tattoos, read-only) | Tab — hold | Select — hold |
| Ledge grab | automatic; climb = jump / ↑, drop = ↓ | same |
How to run it
| System | File | Notes |
|---|---|---|
| Windows | ultraviolet.exe (+ ultraviolet.pck beside it) | SmartScreen may warn about an unsigned file — “More info” → “Run anyway”. |
| Linux | ultraviolet.x86_64 (+ .pck beside it) | chmod +x, then ./ultraviolet.x86_64. |
| macOS | Ultraviolet.app | Unsigned: right-click → Open → confirm, or xattr -dr com.apple.quarantine Ultraviolet.app. |
Quitting: Esc in the main menu closes the game. In a run, Esc opens the pause menu — quit to the main menu from there.
Still pre-alpha — what’s not here
- Placeholder art. The player has a stand-in sprite pack; enemies, terrain and UI are colored rectangles (red melee / yellow archer / blue blocker).
- Placeholder sound. Rough stand-ins, not level-balanced; two music tracks on loop.
- The boss is a stub. “Legion” is a blue blocker with extra HP that locks the exit until it’s down. The real fight — the one the attack referee was built for — is
v0.3.2. - Balance is untuned — damage, enemy HP, costs, the per-room enemy-count ramp.
max_attackers(2), standoff distance (68), the archer’s ring (110), the tap window (0.18 s) — all tuning knobs, not final values.- No death screen, no attempt counter, no meta-progression beyond tattoos.
How to report feedback
BUG — what happened, where (menu / hub / which room), how to reproduce, which platform. For a fight, say how many enemies and which colors.
FEEL — it works, but how does it play? This build is almost entirely feel:
- Two attackers at once, or one? At one it reads like a duel with an audience; at two the pressure holds but it can get busy. Which is better with three or four enemies around — and which with two?
- Did the blocker get too easy? “First hit always lands” was meant to make the block fair. Did it instead disarm the blue enemies — can you just mash them?
- The standoff distance — do enemies circling and backing off after a swing read as waiting their turn, or as cowards wasting your time? Is the archer at ~110 px just annoying?
- The tap window — does a normal attack come out naturally now, without consciously tapping faster? Does the charged attack start too late the other way?
- 3–4 enemies at once, now with the referee — puzzle, or still overwhelming?
Leave feedback in the comments under the game or this post. Testers with the full guide have a focused checklist there.
Thanks for playing something that’s still mostly rectangles.
— SolarNine · v0.3.1-pre-alpha