Skip to content

🔒 Fix XSS in chat/killcam/scoreboard + scope quick-chat to the match - #3

Open
longmaolab wants to merge 1 commit into
VideoGameTips:mainfrom
longmaolab:fix/escape-html-and-scope-chat
Open

🔒 Fix XSS in chat/killcam/scoreboard + scope quick-chat to the match#3
longmaolab wants to merge 1 commit into
VideoGameTips:mainfrom
longmaolab:fix/escape-html-and-scope-chat

Conversation

@longmaolab

Copy link
Copy Markdown
Collaborator

TL;DR (English)

First off — this project is genuinely impressive. A solo-built browser FPS with ~117 weapons, 39 maps, bots, a killcam theater, a full shop economy, and an AI chat? That's a huge amount of working software. The kind of thing a lot of grown-up dev teams never finish.

This PR is a small, focused safety fix. Right now, a player's name and chat text are inserted straight into the page's HTML. That means another player could type something clever into their name/chat that isn't just text — it could be code that runs inside your browser. That's called an XSS bug (Cross-Site Scripting). We fix it in 3 spots with a tiny escapeHtml() helper (one already exists in chat.js:431), lock down the chat color to a safe pattern, and stop quick-chat from leaking across matches.

It's intentionally small and easy to review — no behavior changes for normal players, just safer handling of text that comes from other people.

What this PR changes

  • Add escapeHtml() to public/game.js (mirroring the one in public/chat.js:431) so we have one helper to clean text before it touches innerHTML.
  • Fix chat feed XSSpublic/game.js:1774-1777 (renderChatFeed): escape line.text before it goes into innerHTML.
  • Validate chat colorpublic/game.js:12578-12582 (chatLine socket handler): only accept a safe color (e.g. #fff / #ffcc66 hex or a short allow-list), fall back to #fff otherwise. This stops CSS being injected through the color value.
  • Fix killcam XSSpublic/game.js:8953-8955 (kill-log theater overlay): escape replay.victim and replay.weapon before innerHTML.
  • Fix scoreboard XSSpublic/game.js:12503: escape p.name before innerHTML (or build the row with textContent).
  • Scope quick-chat to the matchserver.js:1019: swap socket.broadcast.emit('chatLine', …) for emitToMatch(p.matchId, 'chatLine', …), matching how bulletFired already works at server.js:1042.

Why it matters (plain language)

When you do element.innerHTML = "Hello " + someName, the browser doesn't treat someName as just letters — it treats it as HTML. So if another player sets their name to something like <img src=x onerror=...>, that code runs in your browser, not theirs. With chat and the scoreboard, an attacker doesn't even need you to click anything — just seeing their name or message is enough to trigger it. The killcam one is sneaky: the bad name gets saved into your kill log (localStorage, pvp_kill_log) and fires every time you open the theater later.

The server currently only trims these strings to a max length (server.js:968 for names, server.js:1019-1024 for chat). Length-trimming is not the same as sanitizing — "><img src=x onerror=alert(1)> is short enough to slip right through. The real fix is on the client, at the moment we build the HTML.

The fix approach

Two safe patterns, both fine:

  1. Escape, then interpolate — run user text through escapeHtml() so <, >, &, " become harmless entities, then drop it into the template literal. Best when we want to keep the surrounding markup/styles.
  2. Use textContent — for plain text like the scoreboard cells, set cell.textContent = p.name so the browser never parses it as HTML at all.

For the color, escaping isn't enough on its own (it's used inside a CSS style="..."), so we validate it against a pattern like /^#[0-9a-fA-F]{3,8}$/ and fall back to #fff if it doesn't match.

For quick-chat scoping, emitToMatch keeps each match in its own bubble (same fix that solved the old cross-match bullet/6v6 issues). The sender's own echo is still filtered client-side by the existing if (data.id === myId) return; check at game.js:12580, so nothing breaks for the sender.

Confirmed locations

What File:line Tainted value
Chat feed innerHTML public/game.js:1774-1777 line.text, line.color
Chat socket handler public/game.js:12578-12582 data.text, data.color (from other players)
Killcam overlay innerHTML public/game.js:8953-8955 replay.victim, replay.weapon
Scoreboard row innerHTML public/game.js:12503 p.name
Quick-chat broadcast server.js:1019 socket.broadcast.emit → should be emitToMatch
Existing helper to copy public/chat.js:431 escapeHtml() reference impl

Out of scope (gentle note for later)

Per CLAUDE.md, passwords are stored in plaintext on purpose — that's a documented tradeoff for a hobby/learning project, and totally reasonable while you're building and experimenting. The reason it's worth flagging here: XSS + plaintext passwords is a spicy combo. If someone can run code in your browser, and there's a plaintext password sitting in localStorage (pvp_user, set at game.js:17542), they could potentially grab it. Fixing the XSS in this PR already removes the dangerous half of that combo, so you're in good shape. Hashing passwords (bcrypt/argon2) is a great "someday, if this goes production" upgrade — not something this PR needs to touch.


中文详细说明

先说一句:这个项目真的很牛。一个人独立做出来的浏览器 FPS,约 117 把武器、39 张地图、机器人 AI、击杀回放剧场(killcam theater)、完整的商店经济系统,还有 AI 聊天 —— 这个工作量非常大,很多成年人的开发团队都未必能做完。为你点赞 👏

这个 PR 是一个小而专注的安全修复,对正常玩家没有任何体验上的改动,只是更安全地处理「来自其他玩家的文字」。

这是什么问题?(XSS / 跨站脚本)

游戏里有几个地方,是这样往页面里塞内容的:

element.innerHTML = "Hello " + 别人的名字;

问题在于:浏览器不会把「别人的名字」当成纯文字,而是当成 HTML 代码来解析。所以如果有个玩家把自己的名字改成类似 <img src=x onerror=...> 这样的东西,这段代码就会在你的浏览器里运行,而不是在他的浏览器里。这就叫 XSS(跨站脚本攻击)

更麻烦的是:聊天和记分板这两个地方,攻击者根本不需要你点任何东西 —— 你只要「看到」他的名字或消息,代码就触发了。而击杀回放那个更阴险:坏名字会被存进你的击杀日志(localStorage 里的 pvp_kill_log),以后你每次打开剧场都会再触发一次。

服务器现在只是把这些字符串截断到最大长度(名字在 server.js:968,聊天在 server.js:1019-1024)。但「截断长度」不等于「消毒」—— 像 "><img src=x onerror=alert(1)> 这种攻击代码很短,照样能溜进来。真正该修的地方,是在客户端「拼 HTML 的那一刻」。

这个 PR 改了哪些地方

  • public/game.js 加一个 escapeHtml() 帮助函数(照搬 public/chat.js:431 里已经有的那个),统一用它在文字进入 innerHTML 之前清理一遍。
  • 修复聊天框 XSS —— public/game.js:1774-1777renderChatFeed 函数):line.textinnerHTML 之前先转义。
  • 校验聊天颜色 —— public/game.js:12578-12582chatLine socket 处理器):颜色只接受安全格式(比如 #fff 这种十六进制,或一个小白名单),不符合就回退到 #fff。这样能挡住通过 color 注入的 CSS。
  • 修复击杀回放 XSS —— public/game.js:8953-8955(killcam 剧场覆盖层):replay.victimreplay.weaponinnerHTML 之前先转义。
  • 修复记分板 XSS —— public/game.js:12503p.name 转义后再用(或者干脆用 textContent 来建这一行)。
  • 把快捷聊天限制在同一局比赛内 —— server.js:1019:把 socket.broadcast.emit('chatLine', …) 换成 emitToMatch(p.matchId, 'chatLine', …),跟 server.js:1042 那里 bulletFired 已经在用的写法保持一致。

修复思路

两种安全写法,都可以:

  1. 先转义、再插值 —— 让用户文字过一遍 escapeHtml(),把 <>&" 变成无害的实体字符,然后再放进模板字符串。想保留外面那层样式/标签时用这个。
  2. textContent —— 像记分板那种纯文字格子,直接 cell.textContent = p.name,浏览器压根就不会把它当 HTML 解析。

颜色那个光转义还不够(它是放在 CSS 的 style="..." 里面用的),所以要用类似 /^#[0-9a-fA-F]{3,8}$/ 的正则去校验,不匹配就回退成 #fff

快捷聊天的范围问题emitToMatch 能让每一局比赛各自独立(就是当年修好「子弹串场 / 陌生人一进来就变 6v6」那个老 bug 的同一招)。发送者自己的回声,仍然由 game.js:12580 那行已有的 if (data.id === myId) return; 在客户端过滤掉,所以对发送者来说不会有任何破坏。

已确认的位置(都来自核实过的证据)

内容 文件:行号 被污染的值
聊天框 innerHTML public/game.js:1774-1777 line.textline.color
聊天 socket 处理器 public/game.js:12578-12582 data.textdata.color(来自其他玩家)
killcam 覆盖层 innerHTML public/game.js:8953-8955 replay.victimreplay.weapon
记分板行 innerHTML public/game.js:12503 p.name
快捷聊天广播 server.js:1019 socket.broadcast.emit → 应改为 emitToMatch
可参考的现成函数 public/chat.js:431 escapeHtml() 实现

暂不处理(温和地留个「以后再说」)

根据 CLAUDE.md,密码是故意明文存储的 —— 这是你为这个业余/学习项目做的、有记录在案的取舍,在你边做边玩的阶段完全合理。之所以在这里提一句,是因为:XSS + 明文密码 是个有点危险的组合。如果有人能在你浏览器里跑代码,而 localStorage 里又躺着一个明文密码(pvp_user,在 game.js:17542 写入),他就有可能把它偷走。这个 PR 修好 XSS,其实已经把这个组合里最危险的那一半干掉了,所以你已经稳了。给密码加哈希(bcrypt/argon2)是个很棒的「将来上线了再说」的升级 —— 这个 PR 不需要碰它。


这个 PR 故意做得小、好审、好合。你已经把最难的部分(整个游戏)做出来了,现在养成「来自别人的文字,先消毒再上屏」这个习惯,就是从「能跑的游戏」走向「专业级代码」的一小步。继续加油,做得真的很棒!🚀

Player-controlled text (names, chat) was interpolated straight into
innerHTML, allowing XSS against other players — including persistent
XSS via the localStorage kill log. Quick-chat was also broadcast
globally (socket.broadcast.emit) instead of to the match.

- Add escapeHtml() + safeColor() helpers to game.js (mirrors chat.js:431)
- Escape chat-feed text + validate colour (renderChatFeed)
- Escape killcam victim/weapon (openKillTheater)
- Escape scoreboard player name (showScoreboard)
- Scope chatLine to emitToMatch(p.matchId, ...) in server.js

No gameplay changes. node --check passes on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@longmaolab

Copy link
Copy Markdown
Collaborator Author

🔗 Follow-up issues (not blocking this PR — the "next steps" from the same friendly review, in suggested order):

  1. 🧹 Good first issue: two tiny safety fixes (dup id="map-row" + weaponModels length guard) #4 🧹 Good first issue — dup id="map-row" + weaponModels length guard
  2. 🗺️ One source of truth: extract the map pool + weapon tables into shared/tables.js #7 🗺️ One source of truth — shared/tables.js (dedupe the map pool + weapon tables)
  3. 🧩 Split the giant public/game.js into modules (start with weapon/item tables + map builders) #5 🧩 Split the giant game.js into modules
  4. 🧪 Add a tiny first test suite + one-command npm test (3 high-value tests) #6 🧪 A tiny test suite + npm test

Tackle them whenever — each stands alone. 加油! 🚀

VideoGameTips added a commit that referenced this pull request Aug 14, 2026
…dels

## People no longer walk like mannequins

The rig had one rigid segment per limb pivoting only at the shoulder and hip,
both legs driven by the same pure sine, amplitude switching between 0 and full
on a single frame. That reads as a shop dummy being slid along the floor, and no
amount of timing tweaks fixes it while the limbs cannot bend.

- Limbs are now two segments with a joint: hip → thigh → KNEE → shin → foot, and
  shoulder → upper arm → ELBOW → forearm. Total lengths are unchanged (0.65 leg,
  0.6 arm) so silhouettes, skins and hitboxes are untouched. Boots are separate
  and deliberately excluded from legLimbs so they stay dark on every skin; both
  new segments ARE in armLimbs/legLimbs so skin recolouring still covers the
  whole limb exactly as before.
- Knees flex on a curve timed into the swing phase, so the shin clears the
  ground instead of the leg scything through it. Feet counter-rotate to keep the
  sole level, which is most of what makes a walk look weighted.
- Elbows stay slightly bent even at rest and never lock straight.
- Per-character gait, drawn once at build time: phase offset, stride length,
  swing amplitude, left/right asymmetry, bob and lean. Identical bots stepping
  in perfect unison was a louder "these are machines" tell than any missing
  joint; three characters now sit at genuinely different phases.
- Gait curve is a sine plus a little second harmonic, because a real leg's swing
  is quicker than its stance.
- Shoulders and hips counter-rotate against each other, the body rolls onto the
  loaded leg, and the head counters the torso so characters keep looking where
  they are going.
- Body bob, twice per stride. Driven off every direct child's captured base Y
  rather than a wrapper group, because the skin code parents helmets, visors,
  ears and the crown straight onto the group — a wrapper would leave those
  hovering while the head moved. Base Y is captured lazily so the crown, added
  later, is picked up when it appears. Name tag is excluded; a jittering label is
  hard to read. Crouch drop is a deliberate 0.12: the legs bottom out only 0.225
  above the group origin, so more puts the boots through the floor mid-slide.
- Starts and stops are eased through a blend factor instead of snapping.

## Weapons

- `mMat`, shared by every hand-built gun, was Lambert — which has no specular
  term at all, so every barrel in the game read as flat charcoal. It is Phong
  now, which gives a highlight to all of them from one line.
- `_genericGun` (83 weapons route through it) gains an upper/lower receiver
  seam, a magazine well, trigger and trigger guard, charging handle, vented
  handguard, gas block, muzzle device, iron sights when no optic occupies the top
  deck, and a stock with a comb and an angled butt pad.
- The core guns predate `_genericGun` and were each a handful of bare boxes.
  Added `_gunDetails()` for the shared furniture and wired up the seven players
  actually hold — AK20 (the default), AK30, SG8, MP40, P90, Pistol, SRX — plus
  per-gun character: the AK's wooden handguard, SG8's shell tube, MP40's
  perforated shroud, SRX's bipod. Explicit dimensions per caller on purpose;
  deriving them from a bounding box would misplace details on the umbrellas,
  baguettes and chainsaws that also live in this file.
- `makeBotWeaponProp` — the model you see on everyone ELSE — was a box and a
  tube. Now has a magazine, stock and butt pad, handguard, muzzle, and a real
  scope for snipers; melee is a tapered shaft with a wrapped grip and weighted
  head; grenades have a fuse cap, spoon and ring.

Verified in the browser, not just node --check: all 121 weapons still align 1:1
with weaponModels, every model still carries `_flash` (gotcha #3), all 121 bot
props build without throwing, no non-finite positions anywhere in any model, and
the rig drives hip/knee/twist/bob through a full stride with finite values and
folds correctly on crouch. Eyeballed six weapons side by side and three
characters mid-stride at different phases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoGameTips added a commit that referenced this pull request Aug 15, 2026
Follows d21c0c2, which covered the 83 guns routed through _genericGun and seven
hand-built core guns. This does the other 31.

Coverage is now accounted for across all 121 weapons:
  83  via _genericGun        (full detail there)
  25  via _gunDetails        (7 previously + 18 here)
  13  already had a trigger and guard, or are thrown items reshaped by hand

Conventional guns (SG100, RPD, BurstRifle, LeverRifle, AutoShotgun, VectorSMG,
Revolver, Shorty, Cycler, HandCannon, MG42, Minigun) get the shared furniture,
each with its own real dimensions and its own exceptions rather than a blanket
pass: no charging handle on a lever action, a break-action double barrel, a
sawn-off, or a revolver; no muzzle brake on rotary barrels or a sawn-off; no
irons where a rail or carry handle already owns the top deck.

_gunDetails grew ox/oy offsets because the MG42 is assembled off-centre and
every detail would otherwise have floated to the left of the gun. Also added
rearSightZ for the short weapons whose rear notch would have landed behind the
receiver.

Four energy weapons (Flamethrower, GrenadeLauncher, Railgun, FreezeGun) had no
trigger at all. They have one now, and nothing else: their business ends are
emitters, not rifled barrels, so no brake was fitted. The grenade launcher gets
a ladder sight since it is the one that would actually have one.

RPG and Bazooka were eight meshes each and did not read as shoulder-fired: the
RPG now has a wooden heat shield, forward grip and folding ladder sight, the
Bazooka a shoulder rest and firing cable.

Thrown items got shape work rather than gun furniture, which would have been
absurd on them:
- Shuriken: four tapered blades around a hub with a hole, replacing three
  crossed slabs. Its material also specified `metalness`, which does nothing on
  a Lambert material — so it had always been flat grey. Phong now.
- Boomerang: tapered two-segment arms with a rounded elbow and painted bands;
  two identical slabs previously read as the letter V.
- ThrowingAxes: a head with an actual axe profile — flared bit, hammer poll,
  collar — plus a tapered haft and leather grip, replacing one brown slab.
- Slingshot: two bands drawn back to a pouch instead of one straight bar. A
  slingshot with nothing to hold the stone is not a slingshot.
- Blowgun: cord bindings and a nocked dart.
- CreamPie: a ring of cream peaks, a swirl, and a stalk on the cherry.
- TrafficCone: second reflective band, bevelled lip, scuffed underside.

Left alone deliberately: Paintball, Crossbow, Flare and ThrowingKnives already
have triggers and guards and would have got doubled geometry; Boombow is a bow;
Taser is a stun unit with fifteen meshes already.

Verified in the browser: all 121 models build, align 1:1 with WEAPONS, carry
`_flash` (gotcha #3), have non-empty children, and contain no non-finite
positions; all 121 bot props build. Eyeballed revolver, MG42, RPG, shuriken,
cream pie and throwing axe side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoGameTips added a commit that referenced this pull request Aug 16, 2026
…tored

Three strands land together because they had become entangled in one file.

## Handcrafted models (equipment-models.js)

New registry of individually built models — 83 ranged, 23 melee, 17 utility —
replacing every generic-factory placeholder, wired in via handcraftedWeapon()
and loaded before game.js. Verified at runtime rather than by syntax alone:
rosters align exactly at 121/121 ranged, 42/42 melee, 55/55 utility; no null,
empty or non-finite models; every ranged model still carries `_flash` (gotcha
#3 — firing throws without it).

The reported missing `window.HandcraftedModels` was stale browser cache. The
file serves fine (200, byte-exact) and the script order is correct, so no
cache-busting query was needed.

Performance: no optimisation warranted. Building all 123 handcrafted items costs
38 ms once (0.31 ms/item), only ONE model with ~20 meshes is ever visible, and
frames sit at 16.7 ms median / 17.5 p95.

## Match isolation (the "strangers in my match" bug)

enterMatch changed matchId silently, telling nobody. So a client kept every body
it had already been told about — the lobby crowd walked into your match with you
and stood frozen there, since position updates ARE match-scoped and never
reached them again. Three holes, all closed:

- movePlayerToMatch() now announces the move both ways: the match being left
  loses sight of the player and their bots, the match being joined gains them.
- matchRoster hands the mover an authoritative list of who is actually present;
  the client drops everyone not on it, guarding client-only entities (training
  dummies, locally simulated bots).
- leaveMatch was never emitted by anything. endMatch sends it now, so finishing
  a match returns you to the lobby instead of parking you in a dead one until
  you close the tab.

Proven with three simulated socket clients against both builds: 12 assertions
fail on the previous server, all 14 pass now.

## Dead code

Removed 100 declarations (-332 lines): 85 orphaned builders left behind by the
handcrafted registry, buildSimpleMelee/buildSimpleSupport (zero callers), and
the universal polish block (polishWeapon/Melee/SupportModel, detailBox and its
materials — each had exactly one reference, its own definition).

KEPT, contrary to the cleanup list, because neither is actually dead:
- `_genericGun` — openKillTheater still builds the killer's in-hand weapon with
  it. Deleting it would have broken the Kill Log theater.
- `_gunDetails` — 25 surviving bespoke builders call it.

## Movement work restored

game.js on disk had rolled back to a pre-overhaul copy, losing work already on
main: applyBlastImpulse, heldItem() (so the air dash threw "not a function"
again), the _extVel integration, and the gravity tuning. Restored from HEAD and
re-verified live: GRAVITY 21 / JUMP_VEL 13.8 -> 4.53 m apex, blast underfoot
8.48 m, throwing knives dash.

Carries in-progress work from another contributor that was already in the file
(BULLET_SPEED_MULTIPLIER, the updateBullets removeBullet refactor). Left
untouched and unstaged: the Vietnam map pool in server.js, and .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoGameTips added a commit that referenced this pull request Sep 3, 2026
Measured before touching anything: 61 of 99 guns had geometry that never touched
the body — 264 parts hanging in mid-air — and only 29.6% of gun materials had
any specular term at all. Both are systematic faults of hand-placing parts by
eye across two thousand lines, so both are fixed in one finishing pass rather
than gun by gun.

weldModelParts treats each part's bounding box as a graph node and "touching" as
an edge. Anything not in the same connected component as the receiver is loose,
and gets nudged the shortest distance on each axis that makes it touch. Whole
clusters move together, so a scope keeps its lens and a bipod keeps both legs.
It iterates, because attaching one cluster can bring others into contact.

shinifyModel swaps MeshLambertMaterial — which has NO specular term, so those
parts could never catch light whatever colour they were — for Phong, with
shininess scaled by the colour's luminance: dark polymer stays fairly matte,
bright metal gets a hard highlight. MeshBasicMaterial is left alone on purpose,
since glows, reticles and muzzle flashes are meant to look self-lit rather than
polished. Conversions are memoised per source material, so the ~1770 meshes
share a small set of new ones.

Both run once at load over weapons, melee and utilities.

Result: floating parts 264 -> 0 across all 99 weapons, and 0 across the 97 melee
and utility models too. Shiny share 29.6% -> 99.5% (the remaining handful are
transparent parts, deliberately skipped). Every weapon still carries _flash
(gotcha #3), and the worst previous offenders — gau19, m134, barrett,
sticker_blaster, sawed_off — were eyeballed to confirm welding tightened them
without distorting the silhouettes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoGameTips added a commit that referenced this pull request Sep 7, 2026
Andy called it: the pieces looked like they were flying around, or like a
square had been slapped on the side. They were. Measured against a classic
receiver (58 mm tall), every attachment on the top deck was hovering in
clear air:

  front sight post   7.0 mm above the handguard
  rear sight ears    4.0 mm above the receiver
  top rail           3.0 mm above the receiver
  red dot / holo     3-4 mm above the rail
  sniper scope       tangent to the rail - a hairline seam
  pan magazine      22 mm above the receiver (a third of the body height)
  classic stock      zero overlap with the receiver
  ejection port     15 mm proud of the flank - the slapped-on square

Three changes, applied in _genericGun (~80 weapons) and _gunDetails (the
hand-built core guns):

1. SEAT. Every part is now sunk 3 mm into whatever it mounts to instead of
   balanced on top of it. A part that merely touches shows background at
   the joint and reads as floating; sunk slightly, the volumes merge.

2. Connective geometry, because two boxes meeting is not a joint. Sights
   grow out of base blocks. Optics are clamped by real mounting rings and
   posts. A trunnion collar covers the receiver-to-handguard step. A wrist
   tang tapers the receiver into the stock. The charging handle rides in a
   milled raceway. The magwell gets a flared lip. Suppressors get a thread
   shoulder. The ejection port became a shallow surround with a recess
   behind it, proud by 1 mm instead of 15.

3. _softBox: a chamfered box helper. A hard 90 degree corner takes nearly
   the same shade on both faces under one light, so a pile of boxes reads
   as one flat grey mass. A small bevel catches a highlight along every
   edge, which is what separates the forms and gives the machined look.
   Used for all the major volumes.

Also fixed pistols, whose magazine hung under the middle of the frame as
its own slab - pistols get no magwell, so it entered nothing. It now sits
inside the grip on the grip's angle with the baseplate proud at the heel.

Verified in-page: all 3000 shape/scope/mag/stock permutations build with
no errors and all keep _flash (gotcha #3). ~43 meshes per gun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoGameTips added a commit that referenced this pull request Sep 7, 2026
Andy's note after looking at Rivals: the weapons there are not complicated,
they are consistent. Nothing changes length, nothing floats, and there are
no extra parts that do not mean anything. Three real bugs turned up.

1. THE GATLING GUN CHANGED SHAPE AS IT SPUN. The three barrels were not on
   a common circle - barrel 1 at radius 0.0220, barrels 2 and 3 at 0.0246,
   11.8% further out, spaced 116.6/126.8/116.6 instead of 120 apart. So the
   silhouette wobbled while firing. Positions are generated from the angle
   now, so they cannot drift again.

2. NOTHING SHARED A SCALE. Every detail in _genericGun was a hardcoded
   absolute size while the bodies vary a lot, so the SAME grip came out at
   74% of the receiver width on a pistol and 37% on an LMG. Details now
   scale with the body through two factors (0.6 exponent, because a grip is
   sized by a hand as much as by the gun). Roster-wide proportion swing:

     grip  2.00x -> 1.31x     magazine 1.94x -> 1.31x
     rail  1.97x -> 1.31x     sights   1.67x -> 1.19x
     stock 1.98x -> 1.00x     trigger  1.58x -> 1.20x

   Rail slot count now follows rail length so the pitch stays even.

3. PARTS THAT DO NOT BELONG. A pistol works a slide, not a charging handle
   on the flank; an energy weapon has no cartridge to chamber and no gas to
   tap. Both are gated by action type now.

Melee and utilities, same principle:

  BAT - was tapered backwards. rotation.x = +PI/2 maps the cylinder's +Y end
  to +Z, so the wide 0.035 end sat at the hand and the 0.015 end was the
  striking tip. It was also three meshes in two colours, so the joints read
  as steps in what is really one unbroken curve. Now a single lathed
  surface, widest at the tip, narrowing all the way to the knob, no seams
  and nothing bolted on.

  FRAG GRENADE - the four segmented ridges were tori of outer radius 0.036
  inside a sphere of radius 0.045, buried 9 mm under the surface. Not one
  was ever visible: four meshes of pure cost, warping around inside the
  body. Deleted. It has what a grenade actually needs instead - fuse neck
  and cap, a safety lever running down the side, and the ring you pull,
  each overlapping what it attaches to. Body sphere went to 14x10 because
  on a coarse sphere the flat of a facet sits 1.7 mm inside the nominal
  radius, enough for the lever to poke back out low down.

Verified in-page: all 196 models build with no errors (99 weapons, 42
melee, 55 support) and all 99 weapons keep _flash (gotcha #3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant