Rebalance weapon tiers via quantitative DPS/range/accuracy/pool scoring

tools/score_weapons.js ranks the arsenal with a behavior-aware model:
effective DPS (bounce/boomerang/AoE/DoT multipliers), real engagement range,
spread-based accuracy, and damage pool per ammo load. Tier moves:
- Cleaver Cadence 0->1 (440px range + return re-hit outguns the starters)
- Bile Blunderbuss 1->2 (134 effDPS + slow + pools is secretly a heavy)
- The Cauterizer 2->3 (252 effDPS, highest sustained DPS in the game)
- The Tenderizer 2->3 (134 effDPS AoE, outscores Spinal Tap)

Tiers now: T0 repeater/marrow, T1 cleaver/saw/hemophage/eye,
T2 bile/guthook/fleshmasher/trapqueen/redhand, T3 cauterizer/tenderizer/
spinaltap/swarmjar. Smoke test pins the 2/4/5/4 distribution.
This commit is contained in:
MeatSlicer Developer committed 2026-07-30 16:35:21 +10:00
1 parent d5b1828df4
commit f71d202850
4 files changed
+162 -60

No files matched your search

+56 -56
View File
@@ -12,7 +12,7 @@ Weapons have **no upgrade levels** — their stats are fixed. All scaling comes
- Press **R** to swap between them. Holstered weapons keep their remaining ammo.
- Picking up a new special weapon drops your current one (with its remaining ammo) so you can swap back.
- When a special weapon runs dry, you automatically fall back to the Bone Popper.
- Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's +15% `ammoEff` stretches every magazine).
- Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's additive `ammoEff` stretches every magazine).
### Where weapons come from
@@ -97,6 +97,10 @@ Rapid-fire bullet hose. Low per-shot damage, excellent proc-per-second carrier f
Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but the short lifetime makes it strictly close-range.
---
## Tier 1 — Uncommon
### Cleaver Cadence
> *It always comes back*
@@ -112,10 +116,6 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but
Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you (caught within 20 px). **Always pierces** — it can hit enemies on both the outbound and return trip.
---
## Tier 1 — Uncommon
### Sawblade Launcher
> *Bouncing, hungry steel*
@@ -132,24 +132,6 @@ Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you
Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls into packs.
### Bile Blunderbuss
> *A brutal corrosive torrent; pools linger*
| Stat | Value |
|---|---|
| Damage | 7 |
| Fire interval | 0.12 s (rapid, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 340 |
| Spread | 0.32 rad |
| Range | 0.36 s |
| Pierce | 2 (+ item pierce) |
| Ammo | 70 |
| Recoil | 0.34 |
Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool.
### Hemophage
> *Drinks what it touches*
@@ -186,6 +168,24 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest.
## Tier 2 — Rare
### Bile Blunderbuss
> *A brutal corrosive torrent; pools linger*
| Stat | Value |
|---|---|
| Damage | 7 |
| Fire interval | 0.12 s (rapid, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 340 |
| Spread | 0.32 rad |
| Range | 0.36 s |
| Pierce | 2 (+ item pierce) |
| Ammo | 70 |
| Recoil | 0.34 |
Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool.
### Gut Hook
> *Impales and drags the catch*
@@ -201,24 +201,6 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest.
Harpoon that pierces everything and **drags the first non-boss enemy it hits** along its flight path. When the projectile expires, the dragged enemy takes 50% of the bullet's damage again. Excellent for yanking a priority target out of a pack.
### The Cauterizer
> *Incinerates anything brave enough to get close*
| Stat | Value |
|---|---|
| Damage | 4.5 per flame tick |
| Fire interval | 0.05 s (20 ticks/sec, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 310 |
| Spread | 0.34 rad |
| Range | 0.36 s (very short) |
| Pierce | 2 (+ item pierce) |
| Ammo | 110 |
| Recoil | 0.32 |
Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range.
### Flesh Masher
> *Lobs ground-meat bombs*
@@ -250,21 +232,6 @@ Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s).
Area denial. Traps persist on the floor and damage any enemy that steps on them — pre-lay them along doorways and kiting paths.
### The Tenderizer
> *A room-shaking close-range meat paste*
| Stat | Value |
|---|---|
| Damage | 60 (instant area hit) |
| Fire interval | 0.58 s |
| Range | 46 × rangeMul × √shotSpeedMul from player |
| Blast radius | 70 × rangeMul × √sizeMul |
| Ammo | 26 |
| Recoil | 1.00 (max), screen shake 5 |
Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own.
### Red Right Hand
> *A chainsaw that liquefies anything in reach*
@@ -284,6 +251,39 @@ Continuous melee chainsaw — hold LMB and walk into things. Damage scales with
## Tier 3 — Legendary
### The Cauterizer
> *Incinerates anything brave enough to get close*
| Stat | Value |
|---|---|
| Damage | 4.5 per flame tick |
| Fire interval | 0.05 s (20 ticks/sec, hold to spray) |
| Projectiles per shot | 2 |
| Projectile speed | 310 |
| Spread | 0.34 rad |
| Range | 0.36 s (very short) |
| Pierce | 2 (+ item pierce) |
| Ammo | 110 |
| Recoil | 0.32 |
Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range.
### The Tenderizer
> *A room-shaking close-range meat paste*
| Stat | Value |
|---|---|
| Damage | 60 (instant area hit) |
| Fire interval | 0.58 s |
| Range | 46 × rangeMul × √shotSpeedMul from player |
| Blast radius | 70 × rangeMul × √sizeMul |
| Ammo | 26 |
| Recoil | 1.00 (max), screen shake 5 |
Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own.
### Spinal Tap
> *Charge it. Delete a line of meat*
+4 -4
View File
@@ -6,16 +6,16 @@ const WEAPONS = {
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 140, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 42, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 0, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' },
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' },
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 26, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 1, ammo: 70, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 70, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 44, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
eye: { id: 'eye', name: 'Eye Ballista', held: 58, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 32, interval: 0.30, dmg: 10, spd: 320, range: 0.62, behavior: 'homing', sprite: 'bullet_eye', sfx: 'default', punch: 0.40, desc: 'The eyes follow. They always follow' },
guthook: { id: 'guthook', name: 'Gut Hook', held: 64, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.85, dmg: 48, spd: 720, range: 0.60, behavior: 'pierce_drag', sprite: 'bullet_harpoon', sfx: 'heavy', punch: 0.95, desc: 'Impales and drags the catch' },
cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 110, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' },
cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 3, ammo: 110, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' },
fleshmasher: { id: 'fleshmasher', name: 'Flesh Masher', held: 62, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.75, dmg: 34, spd: 999, behavior: 'lob', sprite: 'bullet_gore', sfx: 'lob', punch: 0.90, desc: 'Lobs ground-meat bombs' },
trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 12, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, ammoWeight: 0.75, desc: 'Bear traps. For bears. And worse' },
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 2, ammo: 26, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 26, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 160, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' },
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 9, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 7, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, ammoWeight: 0.8, desc: 'A jar of friends. They are hungry' },
+2
View File
@@ -147,6 +147,8 @@ check('every weapon has layered render and muzzle geometry', Object.values(ctx.W
Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0));
check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0));
check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16);
check('quantitative tier rebalance: 2/4/5/4 distribution', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).filter(w => w.tier === t).length === [2, 4, 5, 4][t]));
check('every drop tier has at least one weapon', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).some(w => w.tier === t)));
check('visceral DSP and fallback events are wired',
['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function'));
const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => {
+100
View File
@@ -0,0 +1,100 @@
// Quantitative weapon-tier scorer: ranks the arsenal by a composite of
// effective DPS, range, accuracy, and damage pool per ammo load.
// Run: node tools/score_weapons.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const sandbox = { Math, Object, Infinity, NaN };
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', 'weapons.js'), 'utf8'), sandbox);
const WEAPONS = vm.runInContext('WEAPONS', sandbox);
// Behavior-aware effective DPS model. Multipliers are conservative estimates
// of how often the gimmick actually connects in a room of ~8 enemies.
function effectiveDps(w) {
const base = w.dmg / w.interval;
switch (w.behavior) {
case 'bullet': return base;
case 'spread': return (w.dmg * (w.pellets || 1)) / w.interval * 0.75; // not every pellet lands
case 'boomerang': return base * 1.6; // return pass often re-hits
case 'bounce': return base * (1 + Math.min(w.bounces || 0, 3) * 0.35); // wall bounces re-hit
case 'cone': return (w.dmg * 2) / w.interval * 1.15; // 2/volley + acid pools
case 'flame': return (w.dmg * 2) / w.interval * 1.40; // 2/volley + burn DoT
case 'homing': return base; // accuracy handled separately
case 'pierce_drag': return base * 1.4; // pierce + drag re-hit
case 'lob': return base * 1.3; // AoE average multi-hit
case 'lob_trap': return base * 1.3; // direct + trap zone control
case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots
case 'slam': return base * 1.3; // AoE multi-hit
case 'saw': return base * 1.1; // continuous contact
case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce
default: return base;
}
}
// Nominal engagement range in px.
function effectiveRange(w) {
switch (w.behavior) {
case 'lob': case 'lob_trap': case 'lob_swarm': return 520; // cursor-targeted
case 'slam': return 46 * (w.range || 1) + 70; // reach + blast radius
case 'saw': return 90; // chainsaw reach
case 'beam': return 900; // screen-length beam
default: return (w.spd || 300) * (w.range || 0.45); // projectile travel distance
}
}
// 0..1 hit reliability from spread, homing, and targeting style.
function accuracy(w) {
if (w.behavior === 'homing' || w.behavior === 'lob_swarm') return 1.0;
if (['slam', 'saw', 'beam'].includes(w.behavior)) return 0.95; // aimed AoE can't really miss
if (['lob', 'lob_trap'].includes(w.behavior)) return 0.9; // cursor-targeted
return Math.max(0.2, 1 - (w.spread || 0) / 0.6);
}
// Total damage in one ammo load (with the same behavior multipliers as DPS).
function damagePool(w) {
if (w.ammo === Infinity) return Infinity;
const perShot = effectiveDps(w) * w.interval;
return perShot * w.ammo;
}
const rows = [];
for (const w of Object.values(WEAPONS)) {
if (w.tier < 0) continue; // sidearm excluded
rows.push({
id: w.id, name: w.name, cur: w.tier,
dps: effectiveDps(w),
range: effectiveRange(w),
acc: accuracy(w),
pool: damagePool(w),
});
}
const maxDps = Math.max(...rows.map(r => r.dps));
const maxRange = Math.max(...rows.map(r => r.range));
const maxPool = Math.max(...rows.map(r => r.pool));
// linear for DPS/range (real gameplay gaps), log for the pool (huge spread)
const norm = (v, max) => v / max;
const normLog = (v, max) => Math.log1p(v) / Math.log1p(max);
for (const r of rows) {
r.score = 100 * (0.45 * norm(r.dps, maxDps) + 0.15 * norm(r.range, maxRange) + 0.10 * r.acc + 0.30 * normLog(r.pool, maxPool));
}
rows.sort((a, b) => b.score - a.score);
console.log('rank weapon cur DPS rng acc pool score');
console.log('---- ---------------- --- ----- ---- ---- ------ -----');
for (const r of rows) {
console.log(
String(rows.indexOf(r) + 1).padEnd(5),
r.name.padEnd(17),
String(r.cur).padEnd(4),
r.dps.toFixed(1).padStart(6),
String(Math.round(r.range)).padStart(5),
r.acc.toFixed(2).padStart(5),
String(Math.round(r.pool)).padStart(7),
r.score.toFixed(1).padStart(6),
);
}