A Fighting Game Prototype Got Its Hitboxes Right, Then Stick Figures Killed It
I Spent a Day Making a Fighting Game Prototype, All Hit Detection Was Correct, Then the "Art" Talked Me Out of It
Source code repository (v1.0.0)
- GitHub: https://github.com/JohnnySilverhandV/sanguo-duel
- Gitee: https://gitee.com/johnny-silver-hand123/sanguo-duel
- Release (with attachments): v1.0.0 @ GitHub | v1.0.0 @ Gitee
A 1,321-line single-file HTML, zero dependencies, no build, double-click to play. It has Guan Yu and Zhao Yun, can walk, attack, and unleash special moves, with hitbox visualization, frame data panel, and frame-by-frame stepping.
Then a user opened it and said: "Stick figures are really ugly, I can't keep playing."
The project ended after that sentence.
This post doesn't talk about "how hard I tried"; it covers three things: where fighting game code gets hard, where the cost really goes, and when you should stop on the third day.
1. Conclusion first
The cost structure of fighting games is "code is reusable, art is strictly linear".
The code half is one-time: hit detection system, state machine, frame data table, hit resolution. After it's done, adding a character just means filling in data on the table. The art half is strictly linear: a character's frame-by-frame animation needs 300–600 images; adding a character adds three hundred images, with zero leverage.
And there's an even harder constraint in this project: AI cannot produce frame-consistent frame-by-frame animation. Generative models have no cross-frame memory: frame 1 has three folds on the cuff, frame 8 has five; video models can produce smooth motion, but the output is compressed pixel video without an alpha channel, so it can't be imported into a game.
Thus the route of "using AI to save money on fighting game art" is blocked under current technology.
2. Hit detection has nothing to do with art
This was the first thing I wanted to verify before starting. Conclusion: whether the sprite on top is Guan Yu's portrait or a red rectangle, the hit detection code doesn't change a single line.
Fighting game hit detection uses a "three-box system":
| Box | Purpose | Who is detected |
|---|---|---|
| hitbox | Where this frame can hit | Only exists on active frames of an attack |
| hurtbox | Where this frame can be hit from | Always exists, changes with pose |
| pushbox | Body occupancy, prevents overlap | Always exists, usually narrower than hurtbox |
Each logic frame runs through a fixed sequence: read input → advance frame → get the frame's box lists → rectangle intersection on each pair → hit resolution.
Why rectangles instead of pixels: for predictability and fairness. Pixel-level detection would make "one pixel difference" a common cause of missed hits, and players would see it as a bug. Fighting games have used rectangles for decades; this is a deliberate choice.
3. How actions work: frame data table
This is the core structure of the whole project. It's not "cutting images on a timer" but writing image indices into a table, read by the state machine per frame.
Guan Yu's light attack looks like this in code:
light: {
frames: [
{ dur: 5, pose: 'lWind' },
{ dur: 3, pose: 'lHit', fx: 'slash',
hit: { ox: 30, oy: -152, w: 78, h: 62,
dmg: 8, hitstun: 15, kb: 3.0, stop: 6 } },
{ dur: 10, pose: 'lRec' }
]
}
Three fields are all it takes:
dur: how many logic frames this frame lasts. It simultaneously determines the speed of the action and the length of the active window.pose: pose name, pointing to a set of bone values in the pose library.hit: whether there is a hitbox this frame. Empty means none.
The special move works the same way, just with more extreme numbers:
special: {
label: 'Qinglong Yanyue Slash',
frames: [
{ dur: 14, pose: 'sWind' },
{ dur: 4, pose: 'sWind2', fx: 'charge' },
{ dur: 5, pose: 'sHit', fx: 'slashBig',
hit: { ox: 24, oy: -172, w: 132, h: 124,
dmg: 24, hitstun: 34, kb: 9.5, stop: 14, heavy: true } },
{ dur: 22, pose: 'sRec' }
]
}
Startup is 18 frames (14+4), active is 5 frames, recovery is 22 frames. The hitbox is 132×124, more than double the light attack's 78×62. stop: 14 is a 14-frame hitstop—that is the first source of impact feel, more important than the visuals.
4. Five structural decisions that decide success or failure
1. A single logic clock. Fixed 60 logic frames per second, accumulated via an accumulator, rendering can interpolate. Hit detection results are identical on 60Hz and 144Hz monitors. Without this, online play never syncs, and different machines feel different.
const STEP = 1 / 60;
acc += dt;
while (acc >= STEP) { step(); acc -= STEP; }
2. Frame data table driven; logic and presentation separated. Adding a move adds only data, not code. This is why hit detection doesn't need to change when swapping art.
3. Pose library with per-character overrides. Common poses plus a per-character override set; adding a wAng field directly controls weapon orientation:
// Guan Yu: blade swings in an arc, from behind the head to the front
lWind: { lean:-12, hipY: 94, armF: {x: -12, y: -78}, wAng: -128 },
lHit: { lean: 24, hipY: 88, armF: {x: 50, y: 22}, wAng: 46 },
// Zhao Yun: spear thrusts straight, first pulled to the side then leveled
The same renderer draws two different weapon personalities.
4. Hit dedup by frame number, not boolean. I wrote this wrong in the first version:
// ✗ Wrong: one move can only hit once, multi-hit moves get collapsed into one hit
if (att.hasHit) return;
// ✓ Correct: dedup by frame number; different frames can each hit once
if (att.hitDone[att.frameIdx]) return;
The three-hit thrust sequence (6+6+11 damage) relies on this. After changing to frame-index dedup, a real 9-second battle's damage breakdown is 23 + 23 + 5×5—two full triple thrusts plus five light pokes, each segment applying separately.
5. Effects layer doesn't participate in hit detection and isn't affected by hitstop. Otherwise, during the freeze frames of a landed hit, the slash effect and sparks freeze too, which weakens visual feedback.
5. Three real pitfalls
Pit 1: Active windows cross sprite frame boundaries
The frame data table indexes by "image"; one image usually lasts 4 logic frames (67ms). But a fighting game light attack's active phase is only 2–4 logic frames.
If an artist evenly splits a 12-frame jab into three images [4,4,4], the hit frame covers logic frames 5–8, while the real active is frames 4–5, and the frame data table can't express that. The player feels "sometimes it hits, sometimes not, and the visible frames don't match the feel."
Fix: the duration of the image that contains the hit frame must equal that move's activeFrames. A jab should be [3,2,7] instead of [4,4,4]. Only loop frames get long durations.
This costs zero on the art side, but if it's not written into the contract, the day you change artists will blow up collectively.
Pit 2: Background tabs throttle requestAnimationFrame
While tuning hit detection, I saw through screenshots that the AI seemed frozen, only moving 20 frames in 12 seconds. My first thought was "the AI logic is broken."
Actually, the browser throttles rAF in background tabs to 1 frame per second, and key edge events get swallowed too. The game logic itself was fine.
The correct approach isn't to increase sleep time; it's to bring the tab to the foreground, then use "pause + frame step" for deterministic verification. Relying on screenshot bursts to capture active frames is unreliable.
Pit 3: The stick figure's weapon stuck into the ground while idle
If weapon orientation is purely derived from the arm vector, and the idle hand is low, the Qinglong crescent blade stabs into the ground. Weapon angle needs its own field (wAng), not derived from the arm.
This kind of "looks fake at a glance" problem will never be discovered by reading code; you have to actually render it and look.
6. The scope account
After the prototype was done, I had three people (designer, engineer, artist) estimate separately. The numbers didn't match, off by four to five times. After aligning assumptions and re-estimating:
| Tier | Characters | Moves/character | Code person-days | Art person-days | Total |
|---|---|---|---|---|---|
| Vertical slice (graybox) | 1 | 1 | 18 | 0 | 18 |
| Minimum playable (pure graybox) | 2 | 5 | 93 | 0 | 93 |
| Minimum playable + full art | 2 | 5 | 93 | ≈60 | ≈153 |
| Standard | 4 | 9 | 183 | 120 | ≈300 |
| Complete | 8 | 10 | 280 | 376 | ≈656 |
Converting to calendar time makes it meaningful:
| Cumulative person-days | Hobby 1 day/week | Part-time 3 days/week | Full-time 5 days/week |
|---|---|---|---|
| 18 | 18 weeks | 6 weeks | 4 weeks |
| 93 | 93 weeks | 31 weeks | 19 weeks |
| 153 | 153 weeks (3 years) | 51 weeks | 31 weeks |
| 300 | 300 weeks (6 years) | 100 weeks (2 years) | 60 weeks |
Key fact: at 4 characters, art is 1.9 times code; at 8 characters, it's 3.1 times. Code grows sub-linearly and AI can speed it up; art is strictly linear and AI speeds up nothing. The decision to "add one more character" lands almost entirely on art assets.
7. How big was KOF'97's team?
About 50–70 people, 10–12 months, which works out to over 13,000 person-days. That's 85 times the 2-character prototype above.
But team size and timeline are only the surface. The real gap lies in three places where we're not on equal footing:
- SNK didn't redraw all 28 characters every year. Most sprites in KOF'97 were ported from '96, '95, even Fatal Fury; some didn't even have colors changed. They started accumulating fighting game sprites in 1991; by 1997 they were building incrementally on six years of inventory. You're building the first floor on empty ground.
- They built their own hardware. Neo Geo is SNK's self-developed platform; toolchains, cartridge writers, debuggers were all internal. Artists drew to hardware spec from day one, so there was no "drew it all and then found it doesn't fit."
- Those 50 people were professional pixel animators. One person drawing 300–600 frames of coherent animation is a trade, thousands of hours of training. They even used the crude method of filming real people as reference and tracing per frame.
8. How modern small teams survive
No successful small team survives by "drawing 300 frames per character." They all survive by changing art style.
| Game | Team | Art strategy | Result |
|---|---|---|---|
| Skullgirls | ~a dozen | High-res hand-drawn per frame, over a thousand frames per character | Crowdfunded $830k to make it; per-character budget later about $250k |
| Divekick | Two people | Minimal geometric shapes + a line | Sold hundreds of thousands of copies |
| Nidhogg | Two people | Pure color silhouettes | Critical and commercial success |
| Single-character fighting games (Footsies, etc.) | One person | Blocks, color patches, very few animations | Used by the fighting game community as training tools |
This is the wall people with art constraints run into: the art cost of a fighting game is almost unaffected by "how simple you draw." Because it doesn't require "pretty"; it requires that every frame of every action can be independently referenced and aligns with the hitbox. This requirement closes half of the road to "reducing drawing volume."
9. When to stop
Looking back, the most expensive lesson of this project isn't technical; it's ordering.
Stick figures as "placeholders to verify feel" are entirely reasonable from an engineering standpoint, but they are placeholders for the developer, not a product for the player. I conflated these two things.
The correct approach: before starting, spend half a day producing one complete character with the finished look—even by using free assets, or a style like ink silhouette that can be drawn in code—and let the stakeholder decide on "ugly or not" first, then discuss architecture.
Because feature validation passing ≠ project viability.
As for those 1,321 lines of code, they're not wasted. Hit detection system, state machine, frame data table, multi-hit detection, hitstop, debug layer—this skeleton only requires swapping the renderer to change art. It's something that can be reused indefinitely, just not right now.
If this post saves you a month of trial and error, it was worth it.
Thinking clearly about whether to do it is more important than grinding for three months.