My own robot-combat game in the spirit of BattleBots and Robot Wars: kids build a robot from realistic parts in a garage, then drive it in a 3D arena. Play it at https://games.503mail.com/bots/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.
What's in it
The garage works like building a real featherweight robot. There's a 30 lb weight limit, and every part has a real-world weight: the frame (aluminum, compact, steel tube, titanium), the drive motors (two or four small 12 volt gear motors, big 24 volt brushed motors, or four brushless motors), the wheels (4 inch rubber up to 8 inch air-filled), the LiPo battery (4S or 6S), the armor (none, aluminum, UHMW plastic, hardened AR500 steel, titanium; armor weight grows with the frame's size), the weapon (wedge, lifter, hammer axe, air flipper with 12 shots, drum spinner, bar spinner) and one extra (front forks, a self-righting bar, or extra top armor). The game works out what the parts add up to and shows it: weight, top speed in mph, pushing power in newtons, spinner energy in kilojoules, minutes of battery, and what happens upside down. Real trade-offs fall out of that. Bigger wheels are faster but push less. 24 volt motors on a 4S battery only get about 60% speed. A small battery runs flat with big motors. Wheels taller than the frame let the robot keep driving upside down. Otherwise it needs a self-righting weapon or bar, or it gets counted out. Everything after the starter kit costs coins, earned by fighting six house robots (Bumper, Scoop, Clank, Whirly, Drumroll and Champ, from easy to expert), with double coins the first time each one is beaten. Fights are 2 minutes (3 with friends) in a 5 m steel box with a corner pit that opens after 45 seconds. A robot is out if it falls in the pit, is destroyed, or can't move for 10 seconds (on its back with no way to flip over, or both drive motors broken). If time runs out, the judges pick the robot that did the most damage. Damage is worked out per side: hits wear down the armor on that side (front, back, left, right, top or bottom), then get through to the frame, the motors on that side or the weapon, so a smashed left side really does slow the left wheels. Fight friends: up to 4 robots in one fight, each kid on their own tablet. Signed in from BlockCraft: like Super Pip, a kid signed in to BlockCraft on the same tablet gets their own coins and robot under their BlockCraft name, and the robot starts in their BlockCraft colour (any of 10 paints can be picked).
Controls
Touch: put a finger anywhere on the left part of the screen and slide it to drive (a joystick appears under the finger). The big red button on the right fires the weapon: SPIN turns a spinner on or off, LIFT lifts while held, FLIP and SMASH fire once. When the robot is upside down, the same button flips it back over (if it has a lifter, flipper, hammer or self-righting bar). 🎥 changes the camera and ⏸ pauses. Keyboard: arrows or W A S D drive, Space fires, C camera, P pause. Steering: Easy (the default) drives the robot toward wherever you push the stick on the screen, so kids don't have to think about which way the robot faces. Classic (in Settings) is like a real radio: up is forward and left/right turn. Steering swaps by itself when the robot is upside down, like the invert switch on a real robot radio. Fight friends: one kid taps Fight friends, then Start a new fight room. The others tap Fight friends and Join that room. The first kid taps Start the fight! when everyone is in.
Rebuild, step by step
Robot Rumble is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Robot Rumble tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/bots
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/bots/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/bots/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#10141a">
<title>Robot Rumble</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#10141a;color:#f2f5f8;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-user-select:none;user-select:none;touch-action:none;-webkit-tap-highlight-color:transparent}
canvas#gl{position:fixed;left:0;top:0;width:100%;height:100%;display:block}
.hide{display:none!important}
.screen{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px;background:rgba(10,14,20,.78);overflow-y:auto}
.screen.top{justify-content:flex-start}
h1{font-size:clamp(30px,8vmin,64px);margin:0;letter-spacing:.06em;color:#ffb020;text-shadow:0 3px 0 #7a4b00;text-align:center}
h2{margin:0;font-size:clamp(20px,4.5vmin,30px);color:#ffb020}
.sub{color:#c9d3df;font-size:clamp(15px,3.2vmin,20px);text-align:center;max-width:640px}
button{font:inherit;font-size:clamp(16px,3.2vmin,21px);color:#fff;background:#2b6fd6;border:0;border-radius:14px;padding:10px 16px;min-height:50px;cursor:pointer;box-shadow:0 3px 0 #173f80}
button:active{transform:translateY(2px);box-shadow:0 1px 0 #173f80}
button:disabled,button.buy:disabled,button.go:disabled{background:#4a5260;box-shadow:none;color:#b8c0cc}
button.big{width:min(86vw,360px);font-size:clamp(18px,3.8vmin,25px)}
button.alt{background:#3a4656;box-shadow:0 3px 0 #222a35}
button.go{background:#21a35a;box-shadow:0 3px 0 #116636}
button.buy{background:#c77d00;box-shadow:0 3px 0 #7a4b00}
a.home{color:#9fc1ff;font-size:18px;margin-top:6px}
.row{display:flex;flex-wrap:wrap;gap:10px;justify-content:center}
.coins{font-size:clamp(18px,3.8vmin,24px);color:#ffd35c;font-weight:700}
.panel{position:fixed;right:0;top:0;bottom:0;width:min(46vw,470px);background:rgba(20,26,34,.95);border-left:2px solid #3a4656;padding:12px;overflow-y:auto;display:flex;flex-direction:column;gap:8px}
@media (orientation:portrait){.panel{left:0;right:0;top:auto;width:auto;height:58vh;border-left:0;border-top:2px solid #3a4656}}
.slot{display:flex;justify-content:space-between;align-items:center;text-align:left;background:#26303d;box-shadow:0 3px 0 #161c24;width:100%}
.slot small{display:block;color:#aeb9c7;font-size:.78em}
.slot b{color:#ffb020}
.opt{background:#26303d;border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;border:2px solid transparent}
.opt.cur{border-color:#21a35a}
.opt .nm{font-weight:700;font-size:1.05em}
.opt .inf{color:#c9d3df;font-size:.92em}
.opt .st{color:#9fc1ff;font-size:.9em}
.stat{display:grid;grid-template-columns:8.5em 1fr;gap:4px 8px;align-items:center;font-size:.95em}
.bar{height:14px;background:#0d1117;border-radius:7px;overflow:hidden}
.bar i{display:block;height:100%;background:#21a35a}
.warn{color:#ff7b6b;font-weight:700}
.good{color:#7ee2a0}
input[type=text]{font:inherit;font-size:18px;padding:8px 10px;border-radius:10px;border:2px solid #3a4656;background:#0d1117;color:#fff;width:100%;-webkit-user-select:text;user-select:text}
.paints{display:flex;flex-wrap:wrap;gap:8px}
.paints button{width:44px;height:44px;min-height:44px;padding:0;border-radius:50%;border:3px solid #10141a}
.paints button[aria-pressed=true]{border-color:#fff}
.list{display:flex;flex-direction:column;gap:10px;width:min(94vw,560px)}
.card{background:#1b222c;border:2px solid #3a4656;border-radius:14px;padding:12px;display:flex;gap:12px;align-items:center;justify-content:space-between}
.card .who{font-weight:700;font-size:1.1em}
.card .inf{color:#c9d3df;font-size:.9em}
/* fight HUD */
#hud{position:fixed;inset:0;pointer-events:none}
#hudTop{position:absolute;top:6px;left:0;right:0;display:flex;justify-content:center;gap:8px;flex-wrap:wrap;padding:0 110px}
.bc{background:rgba(10,14,20,.75);border-radius:10px;padding:4px 8px;min-width:120px;font-size:14px}
.bc .n{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px}
.bc .bar{height:9px;margin-top:3px}
.bc .s{font-size:12px;color:#ffd35c;min-height:14px}
#timer{position:absolute;top:6px;left:8px;font-size:22px;font-weight:800;background:rgba(10,14,20,.75);border-radius:10px;padding:4px 10px}
#msg{position:absolute;top:28%;left:0;right:0;text-align:center;font-size:clamp(28px,8vmin,64px);font-weight:900;color:#ffb020;text-shadow:0 3px 0 #000,0 0 12px #000}
#mine{position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(10,14,20,.75);border-radius:10px;padding:5px 12px;font-size:15px;white-space:nowrap}
.tag{position:absolute;transform:translate(-50%,-100%);font-size:13px;font-weight:800;text-shadow:0 1px 2px #000,0 0 4px #000;white-space:nowrap}
.tag .cnt{display:block;text-align:center;font-size:22px;color:#ff5040}
#hudBtns{position:absolute;top:6px;right:8px;display:flex;gap:8px;pointer-events:auto}
#hudBtns button{min-height:44px;padding:6px 12px}
#fire{position:absolute;right:4vmin;bottom:9vmin;width:24vmin;height:24vmin;max-width:150px;max-height:150px;min-width:96px;min-height:96px;border-radius:50%;
background:#d23a32;box-shadow:0 5px 0 #7a1c16;font-size:clamp(16px,3.6vmin,22px);font-weight:900;pointer-events:auto}
#fire.on{background:#ff7a1a}
#joy{position:absolute;width:130px;height:130px;margin:-65px 0 0 -65px;border-radius:50%;border:3px solid rgba(255,255,255,.35);background:rgba(255,255,255,.08)}
#joy i{position:absolute;left:50%;top:50%;width:56px;height:56px;margin:-28px 0 0 -28px;border-radius:50%;background:rgba(255,255,255,.45)}
#joyHint{position:absolute;left:6vmin;bottom:10vmin;color:rgba(255,255,255,.55);font-size:16px;max-width:30vw}
.sr{position:absolute;left:-9999px}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div id="hud" class="hide">
<div id="timer">3:00</div>
<div id="hudTop"></div>
<div id="tags"></div>
<div id="msg" aria-live="assertive"></div>
<div id="mine"></div>
<div id="joyHint">Touch here and slide to drive</div>
<div id="joy" class="hide"><i></i></div>
<button id="fire">WEAPON</button>
<div id="hudBtns"><button id="bCam" class="alt" aria-label="Change camera">🎥</button><button id="bPause" class="alt" aria-label="Pause">⏸</button></div>
</div>
<div id="title" class="screen">
<h1>ROBOT RUMBLE</h1>
<div class="sub">Build a real fighting robot. Then battle it!</div>
<div id="tWho" class="sub"></div>
<button class="big go" id="bGarage">🔧 Build my robot</button>
<button class="big" id="bPractice">🥊 Practice fights</button>
<button class="big" id="bFriends">👫 Fight friends</button>
<button class="big alt" id="bTest">🚗 Test drive</button>
<button class="big alt" id="bSettings">⚙️ Settings</button>
<a class="home" href="/">⬅ All games</a>
</div>
<div id="garage" class="panel hide">
<div class="row" style="justify-content:space-between;align-items:center"><h2>🔧 Garage</h2><span class="coins" id="gCoins"></span></div>
<label>Robot name <input type="text" id="gName" maxlength="16"></label>
<div>Paint</div>
<div class="paints" id="gPaint"></div>
<div id="gSlots" style="display:flex;flex-direction:column;gap:8px"></div>
<div id="gStats" class="stat"></div>
<div class="row"><button id="gTry" class="alt">💥 Try weapon</button><button id="gDrive">🚗 Test drive</button><button id="gBack" class="go">✔ Done</button></div>
</div>
<div id="picker" class="panel hide">
<div class="row" style="justify-content:space-between;align-items:center"><h2 id="pkTitle"></h2><span class="coins" id="pkCoins"></span></div>
<div id="pkList" style="display:flex;flex-direction:column;gap:10px"></div>
<button id="pkBack" class="go">⬅ Back to garage</button>
</div>
<div id="practice" class="screen top hide">
<h2>🥊 Practice fights</h2>
<div class="sub">Beat the house robots to earn coins. Coins buy better parts in the garage.</div>
<div id="prList" class="list"></div>
<button class="big alt" id="prBack">⬅ Back</button>
</div>
<div id="lobby" class="screen top hide">
<h2>👫 Fight friends</h2>
<label style="width:min(90vw,420px)">Your name <input type="text" id="lbName" maxlength="12"></label>
<div id="lbSigned" class="sub hide">Signed in from BlockCraft.</div>
<div id="lbOut" class="list">
<button class="go" id="bCreate">➕ Start a new fight room</button>
<div id="rooms" class="list"></div>
</div>
<div id="lbIn" class="list hide">
<div id="lbRoom"></div>
<button class="go hide" id="bStartMP">🥊 Start the fight!</button>
</div>
<div id="lbStatus" class="sub" aria-live="polite"></div>
<button class="big alt" id="bLbBack">⬅ Back</button>
</div>
<div id="result" class="screen hide">
<h1 id="rTitle"></h1>
<div id="rWhy" class="sub"></div>
<div id="rCoins" class="coins"></div>
<div id="rStats" class="sub"></div>
<div class="row"><button id="rAgain" class="go">🥊 Fight again</button><button id="rGarage">🔧 Garage</button><button id="rMenu" class="alt">Menu</button></div>
</div>
<div id="pause" class="screen hide">
<h2>Paused</h2>
<button class="big go" id="pResume">▶ Keep going</button>
<button class="big alt" id="pPit">Open or close the pit</button>
<button class="big alt" id="pQuit">⬅ Leave</button>
</div>
<div id="settings" class="screen hide">
<h2>⚙️ Settings</h2>
<button class="big" id="sSteer"></button>
<div class="sub" id="sSteerInf"></div>
<button class="big" id="sCam"></button>
<button class="big" id="sQual"></button>
<button class="big" id="sSnd"></button>
<button class="big alt" id="sBack">⬅ Back</button>
</div>
<script>
'use strict';
// Robot Rumble: build a robot from real parts (weights, motors, batteries, armor, weapons), then fight it
// in a 3D arena against house robots or friends. Everything is written from scratch: own WebGL renderer,
// own rigid-body physics (wheels with motor curves and grip, penalty contacts, spinner energy).
// Multiplayer goes through the games relay (/mp/ws, game 'bots'): the room host runs the physics,
// everyone else sends stick + weapon input and draws the host's snapshots.
const $ = id => document.getElementById(id);
const clamp = (x, a, b) => x < a ? a : x > b ? b : x;
const lerp = (a, b, t) => a + (b - a) * t;
const wrap = a => { a = (a + Math.PI) % (2 * Math.PI); if (a < 0) a += 2 * Math.PI; return a - Math.PI; };
const V = {
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
mul: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
madd: (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s],
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
len: a => Math.hypot(a[0], a[1], a[2]),
norm: a => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; },
dist: (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]),
};
// quaternions [x,y,z,w]
const QI = [0, 0, 0, 1];
function qmul(a, b) {
return [a[0] * b[3] + a[3] * b[0] + a[1] * b[2] - a[2] * b[1], a[1] * b[3] + a[3] * b[1] + a[2] * b[0] - a[0] * b[2],
a[2] * b[3] + a[3] * b[2] + a[0] * b[1] - a[1] * b[0], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]];
}
function qrot(q, v) {
const x = q[0], y = q[1], z = q[2], w = q[3];
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2];
return [ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x];
}
const qconj = q => [-q[0], -q[1], -q[2], q[3]];
function qaxis(ax, a) { const s = Math.sin(a / 2); return [ax[0] * s, ax[1] * s, ax[2] * s, Math.cos(a / 2)]; }
function qnorm(q) { const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1; return [q[0] / l, q[1] / l, q[2] / l, q[3] / l]; }
function qnlerp(a, b, t) { const d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3], s = d < 0 ? -1 : 1;
return qnorm([lerp(a[0], b[0] * s, t), lerp(a[1], b[1] * s, t), lerp(a[2], b[2] * s, t), lerp(a[3], b[3] * s, t)]); }
function hex2rgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]; }
const mix3 = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// ---------------- save + BlockCraft sign-in ----------------
const SKEY = 'bots_save';
let SAVE; try { SAVE = JSON.parse(localStorage.getItem(SKEY) || '{}') || {}; } catch (e) { SAVE = {}; }
SAVE.profiles = SAVE.profiles || {};
SAVE.opt = Object.assign({ easy: true, cam: 'arena', q: 1, snd: true, name: '' }, SAVE.opt || {});
function saveNow() { try { localStorage.setItem(SKEY, JSON.stringify(SAVE)); } catch (e) {} }
// Same picker BlockCraft uses for vehicle paint, so a kid's robot starts in their BlockCraft colour.
const BC_PAINTS = ['#d23a32', '#2f6fd8', '#3fae3a', '#8932b8', '#f0c020', '#f07d19', '#f38baa', '#20b8b0'];
const PAINTS = BC_PAINTS.concat(['#e6e8ea', '#2a2d33']);
function bcPaint(n) { const s = n.toLowerCase(); let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return BC_PAINTS[(h >>> 0) % 8]; }
const BC = (() => { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg') || '{}'); if (c.signedIn && c.name) return { name: String(c.name).slice(0, 12), color: bcPaint(String(c.name)), who: String(c.name), key: String(c.code || '') }; } catch (e) {} return null; })();
const myName = () => BC ? BC.name : (SAVE.opt.name || 'Player');
// ---------------- parts (real-world numbers, featherweight class) ----------------
const LIMIT_LB = 30, ELEC_LB = 1.5; // radio receiver, speed controllers, wires and power switch
const PARTS = {
frame: [
{ id: 'alu', name: 'Aluminum box frame', lb: 4, L: .50, W: .38, H: .12, hp: 100, cost: 0, info: 'Light and easy to build. A good start.' },
{ id: 'mini', name: 'Compact aluminum frame', lb: 3, L: .40, W: .34, H: .10, hp: 80, cost: 60, info: 'Small and low, so it is hard to hit. Armor weighs less on a small frame.' },
{ id: 'steel', name: 'Steel tube frame', lb: 7.5, L: .56, W: .44, H: .13, hp: 170, cost: 120, info: 'Very strong, but heavy. Big frames need more armor.' },
{ id: 'ti', name: 'Titanium frame', lb: 4.5, L: .50, W: .40, H: .12, hp: 160, cost: 320, info: 'Strong like steel and light like aluminum. Expensive!' }],
drive: [
{ id: 'g2', name: '2 small gear motors (12 volt)', lb: 2.4, n: 2, v: 12, T: 3.2, rpm: 330, w: 220, cost: 0, info: 'Two-wheel drive. The front slides on skids.' },
{ id: 'g4', name: '4 small gear motors (12 volt)', lb: 4.8, n: 4, v: 12, T: 3.2, rpm: 330, w: 440, cost: 70, info: 'Four-wheel drive: twice the pushing power, a bit slower to turn.' },
{ id: 'b2', name: '2 big brushed motors (24 volt)', lb: 5, n: 2, v: 24, T: 8, rpm: 420, w: 900, cost: 160, info: 'Strong and fast. Needs a 6S battery for full speed.' },
{ id: 'bl4', name: '4 brushless motors (24 volt)', lb: 6, n: 4, v: 24, T: 6.5, rpm: 560, w: 1600, cost: 420, info: 'What champion robots use. Very fast, very strong, uses lots of battery.' }],
wheels: [
{ id: 'r4', name: 'Rubber wheels, 4 inch', lb: .3, r: .051, wd: .032, mu: .95, cost: 0, info: 'Small and grippy.' },
{ id: 'f6', name: 'Foam wheels, 6 inch', lb: .35, r: .076, wd: .04, mu: .8, cost: 50, info: 'Taller than the frame, so your robot can keep driving upside down!' },
{ id: 'g5', name: 'Sticky rubber wheels, 5 inch', lb: .6, r: .0635, wd: .04, mu: 1.15, cost: 120, info: 'The best grip: great for pushing.' },
{ id: 'p8', name: 'Air-filled wheels, 8 inch', lb: 1.1, r: .1, wd: .05, mu: 1.0, cost: 220, info: 'Huge and fast, and they work upside down. Heavy.' }],
battery: [
{ id: 'l4s', name: 'LiPo battery 4S 2200 mAh (15 volt)', lb: .55, v: 14.8, wh: 33, cost: 0, info: 'Light, but runs out quickly with big motors.' },
{ id: 'l6s3', name: 'LiPo battery 6S 3000 mAh (22 volt)', lb: 1.1, v: 22.2, wh: 67, cost: 80, info: 'More volts = faster motors and faster spinners.' },
{ id: 'l6s5', name: 'LiPo battery 6S 5000 mAh (22 volt)', lb: 1.8, v: 22.2, wh: 111, cost: 170, info: 'Lasts a whole fight even with the biggest motors.' }],
armor: [
{ id: 'none', name: 'No armor', lb: 0, hp: 12, spin: 1, cost: 0, col: '#8d969e', info: 'Very light, but every hit goes straight to the insides.' },
{ id: 'alu', name: 'Aluminum plates, 1/8 inch', lb: 3, hp: 55, spin: 1, cost: 0, col: '#c3c9cf', info: 'Light metal armor.' },
{ id: 'uhmw', name: 'Plastic (UHMW), 1/2 inch', lb: 3.5, hp: 60, spin: .65, cost: 60, col: '#ecebe4', info: 'Bendy plastic that soaks up spinner hits.' },
{ id: 'ar', name: 'Hardened steel (AR500), 1/16 inch', lb: 4.5, hp: 100, spin: 1, cost: 130, col: '#565c63', info: 'Very hard steel. Heavy.' },
{ id: 'ti', name: 'Titanium plates, 1/8 inch', lb: 3.2, hp: 105, spin: .9, cost: 320, col: '#9aa3b5', info: 'Strong and light. The best armor money can buy.' }],
weapon: [
{ id: 'wedge', name: 'Steel wedge', lb: 2, cost: 0, kind: 'wedge', info: 'A ramp on the front. Slides under robots so you can push them into the pit.' },
{ id: 'lift', name: 'Lifter arm', lb: 4, cost: 90, kind: 'lift', w: 250, info: 'Hold the button to lift robots and tip them over. Can flip you back over.' },
{ id: 'hammer', name: 'Hammer axe', lb: 5.5, cost: 180, kind: 'hammer', w: 350, info: 'Smashes down on the thin top armor. Can flip you back over.' },
{ id: 'flip', name: 'Air flipper (12 shots)', lb: 6.5, cost: 240, kind: 'flip', info: 'Throws robots into the air. Can flip you back over.' },
{ id: 'drum', name: 'Drum spinner', lb: 7, cost: 260, kind: 'drum', w: 1200, rpm: 7000, I: .0045, info: 'A heavy drum spinning 7000 times a minute. Launches robots up.' },
{ id: 'bar', name: 'Bar spinner', lb: 8.5, cost: 300, kind: 'bar', w: 1500, rpm: 2800, I: .06, info: 'A steel bar spinning 2800 times a minute. The hardest hits of all.' }],
extra: [
{ id: 'none', name: 'Nothing', lb: 0, cost: 0, info: 'Save the weight.' },
{ id: 'forks', name: 'Wedgelets (front forks)', lb: .8, cost: 50, info: 'Little steel forks that slide under other robots.' },
{ id: 'srim', name: 'Self-righting bar', lb: 1, cost: 70, info: 'A bar that flips your robot back over. Press the weapon button when you are upside down.' },
{ id: 'top', name: 'Extra top armor', lb: 1.5, cost: 60, info: 'A thick top plate against hammers and flips.' }]
};
const SLOTS = [['frame', 'Frame'], ['drive', 'Motors'], ['wheels', 'Wheels'], ['battery', 'Battery'], ['armor', 'Armor'], ['weapon', 'Weapon'], ['extra', 'Extra']];
const DEF_DESIGN = { frame: 'alu', drive: 'g2', wheels: 'r4', battery: 'l4s', armor: 'alu', weapon: 'wedge', extra: 'none' };
function part(slot, id) { return PARTS[slot].find(p => p.id === id) || PARTS[slot][0]; }
const AREA0 = 2 * (.5 * .38 + .5 * .12 + .38 * .12);
function stats(d) {
const f = part('frame', d.frame), dr = part('drive', d.drive), wh = part('wheels', d.wheels), ba = part('battery', d.battery),
ar = part('armor', d.armor), we = part('weapon', d.weapon), ex = part('extra', d.extra);
const armLb = ar.lb * 2 * (f.L * f.W + f.L * f.H + f.W * f.H) / AREA0; // armor weight grows with the frame's surface
const lb = f.lb + dr.lb + wh.lb * dr.n + ba.lb + armLb + we.lb + ex.lb + ELEC_LB;
const kg = lb * 0.4536;
const vs = clamp(ba.v / dr.v, 0.6, 1.25); // motors run slower on too few volts, a bit faster on more
const top = dr.rpm * vs * 2 * Math.PI * wh.r / 60;
const push = Math.min(dr.n * dr.T * vs / wh.r, wh.mu * kg * 9.81 * (dr.n === 4 ? 1 : 0.75));
const inv = 2 * wh.r > f.H + 0.02;
const sr = ['flip', 'lift', 'hammer'].includes(we.kind) || ex.id === 'srim';
const watts = dr.w * vs * 0.45 + (we.w || 0) * vs * 0.3;
const mins = ba.wh / watts * 60;
const kj = we.I ? 0.5 * we.I * Math.pow(we.rpm * vs * Math.PI / 30, 2) / 1000 : 0;
return { f, dr, wh, ba, ar, we, ex, lb, kg, vs, top, push, inv, sr, mins, kj, armLb };
}
const MPH = 2.237;
// ---------------- house robots ----------------
const HOUSE = [
{ id: 'bumper', name: 'Bumper', lvl: 0, reward: 30, col: '#3fae3a', d: { frame: 'alu', drive: 'g2', wheels: 'r4', battery: 'l4s', armor: 'alu', weapon: 'wedge', extra: 'none' }, info: 'A slow wedge robot. Push it into the pit!' },
{ id: 'scoop', name: 'Scoop', lvl: 1, reward: 50, col: '#f0c020', d: { frame: 'alu', drive: 'g4', wheels: 'r4', battery: 'l6s3', armor: 'uhmw', weapon: 'lift', extra: 'none' }, info: 'Lifts robots and tips them over.' },
{ id: 'clank', name: 'Clank', lvl: 1, reward: 60, col: '#8932b8', d: { frame: 'steel', drive: 'g4', wheels: 'f6', battery: 'l6s3', armor: 'alu', weapon: 'hammer', extra: 'none' }, info: 'A heavy hammer robot that drives upside down too.' },
{ id: 'whirly', name: 'Whirly', lvl: 2, reward: 80, col: '#d23a32', d: { frame: 'alu', drive: 'b2', wheels: 'r4', battery: 'l6s3', armor: 'ar', weapon: 'bar', extra: 'none' }, info: 'A bar spinner. Stay out of the way of the bar!' },
{ id: 'drumroll', name: 'Drumroll', lvl: 2, reward: 80, col: '#20b8b0', d: { frame: 'mini', drive: 'b2', wheels: 'g5', battery: 'l6s3', armor: 'ti', weapon: 'drum', extra: 'none' }, info: 'A small drum spinner that launches robots up.' },
{ id: 'champ', name: 'Champ', lvl: 3, reward: 120, col: '#f07d19', d: { frame: 'ti', drive: 'bl4', wheels: 'f6', battery: 'l6s3', armor: 'ti', weapon: 'flip', extra: 'none' }, info: 'The champion: a very fast flipper.' }];
function prof() {
const k = BC ? BC.name : '_local';
let p = SAVE.profiles[k];
if (!p) { p = SAVE.profiles[k] = { coins: 100, owned: {}, design: Object.assign({ col: BC ? BC.color : '#2f6fd8' }, DEF_DESIGN), bot: (BC ? BC.name + "'s Bot" : 'My Bot'), w: 0, l: 0, beat: {} }; saveNow(); }
for (const [s] of SLOTS) if (!PARTS[s].some(x => x.id === p.design[s])) p.design[s] = DEF_DESIGN[s];
return p;
}
const owns = (p, slot, id) => part(slot, id).cost === 0 || !!p.owned[slot + ':' + id];
// ---------------- arena ----------------
const A = 2.5; // the arena is 5 m x 5 m, about the size of a real featherweight box
const PIT = { x: 1.7, z: -1.7, h: 0.42 };
const PIT_AT = 45; // seconds into a fight
const inPit = p => Math.abs(p[0] - PIT.x) < PIT.h && Math.abs(p[2] - PIT.z) < PIT.h;
const SPAWN = [[-1.6, 0, 0], [1.6, 0, Math.PI], [0, 1.6, -Math.PI / 2], [0, -1.6, Math.PI / 2]];
const GRAV = 9.81, FIX = 1 / 60, SUB = 5;
// ---------------- building a robot ----------------
const SAMP = [];
for (const i of [-1, 0, 1]) for (const j of [-1, 0, 1]) for (const k of [-1, 0, 1]) if (i || j || k) SAMP.push([i, j, k]);
const CORN = SAMP.filter(s => s[0] && s[1] && s[2]);
function addCol(b, c, h, q, kind, g) { const col = { c, h, q: q || QI, kind, g: g || CORN.map(s => [s[0] * h[0], s[1] * h[1], s[2] * h[2]]), solid: true }; b.cols.push(col); return col; }
// a flat plate from (x0,y0) to (x1,y1) in the robot's side view. The collider is thicker than the plate
// (so fast robots can't pass through it) and grows downward, so its top face is the plate you see.
function addPlate(b, x0, y0, x1, y1, hw, kind, zc) {
const a = Math.atan2(y1 - y0, x1 - x0), len = Math.hypot(x1 - x0, y1 - y0), th = 0.05, n = [-Math.sin(a), Math.cos(a), 0];
const ctr = [(x0 + x1) / 2, (y0 + y1) / 2, zc || 0], q = qaxis([0, 0, 1], a);
const h = [len / 2, th / 2, hw];
addCol(b, V.madd(ctr, n, -(th / 2 - 0.006)), h, q, kind, [[-h[0], h[1], -h[2]], [h[0], h[1], -h[2]], [-h[0], h[1], h[2]], [h[0], h[1], h[2]]]);
b.vis.push({ c: ctr, q, s: [len, 0.012, hw * 2] });
}
function setPlate(col, piv, a, len, hw) { // moving arm plate
const q = qaxis([0, 0, 1], a), dir = [Math.cos(a), Math.sin(a), 0], n = [-Math.sin(a), Math.cos(a), 0], th = 0.04;
col.c = V.madd(V.madd(piv, dir, len / 2), n, -(th / 2 - 0.006)); col.q = q; col.h = [len / 2, th / 2, hw];
col.g = [[-len / 2, th / 2, -hw], [len / 2, th / 2, -hw], [-len / 2, th / 2, hw], [len / 2, th / 2, hw]];
}
let nextLocalId = 1000;
function buildBot(d, o) {
const s = stats(d), f = s.f, wh = s.wh, m = s.kg;
const b = {
d, s, id: o.id != null ? o.id : nextLocalId++, name: o.name || 'Robot', col: d.col || '#2f6fd8', ctrl: o.ctrl || 'none', ai: o.ai || null, house: o.house || null,
L: f.L, W: f.W, H: f.H, m, p: [0, 0, 0], v: [0, 0, 0], q: [0, 0, 0, 1], w: [0, 0, 0], F: [0, 0, 0], T: [0, 0, 0],
input: { x: 0, y: 0, f: false }, prevF: false, press: false, cmd: [0, 0], out: false, why: '', immob: 0, dealt: 0, hitCool: {}, srCool: 0,
touch: false, wasTouch: true, flash: 0, pts: [], cols: [], vis: [], smokeT: 0, pw: 1, near: 0
};
const H2 = f.H * 1.4, I = [m / 12 * (H2 * H2 + f.W * f.W) * 1.3, m / 12 * (f.L * f.L + f.W * f.W) * 1.3, m / 12 * (f.L * f.L + H2 * H2) * 1.3];
b.invI = I.map(x => 1 / x);
// wheels: in the middle of the frame if they are taller than it (drives upside down), else low so they stick out below
const ay = s.inv ? 0 : -f.H / 2 + wh.r - 0.012;
b.gY = ay - wh.r; // floor height in robot coordinates when standing flat
const zW = f.W / 2 + wh.wd / 2 + 0.006, xs = s.dr.n === 4 ? [f.L * 0.3, -f.L * 0.3] : [-f.L * 0.1];
b.wheels = [];
for (const x of xs) for (const sd of [-1, 1]) b.wheels.push({ c: [x, ay, sd * zW], r: wh.r, wd: wh.wd, side: sd, mu: wh.mu, ang: 0, Fmax: s.dr.T * s.vs / wh.r });
b.vmax = s.dr.rpm * s.vs * 2 * Math.PI * wh.r / 60;
b.wPer = s.dr.w * s.vs / s.dr.n;
b.kW = m * GRAV / (b.wheels.length * 0.006); b.cW = 2 * Math.sqrt(b.kW * m / b.wheels.length) * 0.5;
b.kG = 9000; b.cG = 2 * Math.sqrt(b.kG * m / 4) * 0.5;
addCol(b, [0, 0, 0], [f.L / 2, f.H / 2, f.W / 2], QI, 'body');
const W = b.wep = { k: s.we.kind, a: 0, a0: 0, om: 0, omMax: 0, ang: 0, on: false, shots: 12, cool: 0, st: 0 };
const L2 = f.L / 2, gY = b.gY;
switch (W.k) {
case 'wedge': addPlate(b, L2, f.H * 0.22, L2 + 0.14, gY + 0.005, f.W * 0.5, 'wedge'); break;
case 'lift': W.piv = [L2 - 0.01, gY + 0.035, 0]; W.len = 0.2; W.a0 = W.a = -Math.asin(0.03 / 0.2); W.hw = f.W * 0.4; W.col = addCol(b, [0, 0, 0], [.1, .02, .1], QI, 'arm'); break;
case 'flip': addPlate(b, L2, f.H * 0.05, L2 + 0.1, gY + 0.005, f.W * 0.45, 'wedge');
W.piv = [-f.L * 0.3, f.H / 2 + 0.008, 0]; W.len = f.L * 0.8; W.a0 = W.a = 0; W.hw = f.W * 0.42; W.col = addCol(b, [0, 0, 0], [.1, .02, .1], QI, 'arm'); break;
case 'hammer': W.piv = [-f.L * 0.12, f.H / 2 + 0.03, 0]; W.len = 0.34; W.a0 = W.a = 2.0; W.aHit = Math.asin(clamp((gY + 0.045 - W.piv[1]) / W.len, -1, 1));
W.col = addCol(b, [0, 0, 0], [0.035, 0.04, 0.065], QI, 'head'); break;
case 'bar': W.hub = [L2 - 0.03, f.H / 2 + 0.025, 0]; W.R = 0.27; W.col = addCol(b, W.hub.slice(), [W.R, 0.0125, 0.03], QI, 'spin', []); break;
case 'drum': W.R = 0.06; W.hub = [L2 + 0.075, gY + 0.078, 0]; W.wd = f.W * 0.85; addCol(b, W.hub.slice(), [W.R, W.R, W.wd / 2], QI, 'drum'); break;
}
if (W.k === 'bar' || W.k === 'drum') W.omMax = s.we.rpm * s.vs * Math.PI / 30;
if (s.ex.id === 'forks') for (const z of [-1, 1]) addPlate(b, L2 - 0.01, gY + 0.03, L2 + (W.k === 'drum' ? 0.17 : 0.12), gY + 0.003, 0.018, 'wedge', z * (f.W / 2 - 0.025));
b.rad = Math.hypot(L2 + 0.25, f.W / 2 + 0.08);
const ah = s.ar.hp, side = [ah * (W.k === 'wedge' || W.k === 'flip' ? 1.35 : 1), ah * 0.85, ah, ah, ah * 0.6 + (s.ex.id === 'top' ? 50 : 0), ah * 0.5];
b.hp = { arm: side.slice(), armMax: side, frame: f.hp, frameMax: f.hp, drv: [1, 1], wep: 1, bat: s.ba.wh, batMax: s.ba.wh };
updArms(b);
return b;
}
function place(b, x, z, yaw) { b.p = [x, -b.gY + 0.004, z]; b.q = qaxis([0, 1, 0], yaw); b.v = [0, 0, 0]; b.w = [0, 0, 0]; }
function updArms(b) {
const W = b.wep;
if (W.k === 'lift' || W.k === 'flip') setPlate(W.col, W.piv, W.a, W.len, W.hw);
else if (W.k === 'hammer') { W.col.c = V.madd(W.piv, [Math.cos(W.a), Math.sin(W.a), 0], W.len); W.col.q = qaxis([0, 0, 1], W.a); }
else if (W.k === 'bar') { W.col.q = qaxis([0, 1, 0], W.ang); W.col.solid = W.om < W.omMax * 0.3; }
}
const fwd = b => qrot(b.q, [1, 0, 0]), upv = b => qrot(b.q, [0, 1, 0]);
// ---------------- physics ----------------
function velAt(b, pt) { const r = V.sub(pt, b.p); return V.add(b.v, V.cross(b.w, r)); }
function addF(b, f, pt) {
b.F[0] += f[0]; b.F[1] += f[1]; b.F[2] += f[2];
const r = V.sub(pt, b.p); b.T[0] += r[1] * f[2] - r[2] * f[1]; b.T[1] += r[2] * f[0] - r[0] * f[2]; b.T[2] += r[0] * f[1] - r[1] * f[0];
}
function invIw(b, t) { const tb = qrot(qconj(b.q), t); return qrot(b.q, [tb[0] * b.invI[0], tb[1] * b.invI[1], tb[2] * b.invI[2]]); }
function addJ(b, J, pt) { b.v = V.madd(b.v, J, 1 / b.m); b.w = V.add(b.w, invIw(b, V.cross(V.sub(pt, b.p), J))); }
function hull(b) { // all sample points of all colliders (corners, edge middles, face middles) in world space
let n = 0;
for (const c of b.cols) {
c.cw = V.add(b.p, qrot(b.q, c.c)); c.qw = qmul(b.q, c.q);
if (!c.solid) continue;
for (const s of SAMP) { const pt = b.pts[n] || (b.pts[n] = { p: null }); pt.p = V.add(c.cw, qrot(c.qw, [s[0] * c.h[0], s[1] * c.h[1], s[2] * c.h[2]])); n++; }
}
const bf = fwd(b), bu = upv(b);
for (const w of b.wheels) {
const wc = V.add(b.p, qrot(b.q, w.c));
for (const dv of [bf, bu]) for (const sg of [-1, 1]) { const pt = b.pts[n] || (b.pts[n] = { p: null }); pt.p = V.madd(wc, dv, sg * w.r); n++; }
}
b.pts.length = n;
}
function groundPt(b, s, mu) {
if (s[1] >= 0 || (G.pitOpen && inPit(s))) return;
const vel = velAt(b, s);
let Fn = b.kG * Math.min(-s[1], 0.05) - b.cG * vel[1]; if (Fn <= 0) return;
Fn = Math.min(Fn, b.m * GRAV * 6);
let fx = -vel[0] * 250, fz = -vel[2] * 250; const fl = Math.hypot(fx, fz), mx = mu * Fn;
if (fl > mx) { fx *= mx / fl; fz *= mx / fl; }
addF(b, [fx, Fn, fz], s); b.touch = true;
}
function wallPt(b, s) {
for (const ax of [0, 2]) {
const d = Math.abs(s[ax]) - A; if (d <= 0) continue;
const sg = s[ax] > 0 ? -1 : 1, vel = velAt(b, s), vn = vel[ax] * sg;
let Fn = 20000 * Math.min(d, 0.05) - 300 * vn; if (Fn <= 0) continue;
const f = [-vel[0] * 120, -vel[1] * 120, -vel[2] * 120]; f[ax] = 0; const fl = V.len(f), mx = 0.25 * Fn; if (fl > mx) { f[0] *= mx / fl; f[1] *= mx / fl; f[2] *= mx / fl; }
f[ax] = Fn * sg; addF(b, f, s);
}
}
function wheels(b, dt) {
const bf = fwd(b), br = qrot(b.q, [0, 0, 1]);
let P = 0;
for (const w of b.wheels) {
const a = V.add(b.p, qrot(b.q, w.c));
// the lowest point of the tyre: straight down, flattened onto the wheel's own plane
let d = [br[0] * br[1], -1 + br[1] * br[1], br[2] * br[1]]; const dl = V.len(d);
const cmd = b.cmd[w.side < 0 ? 0 : 1];
if (dl < 0.35) { w.ang -= cmd * 20 * dt; continue; } // robot on its side: the wheel spins in the air
d = V.mul(d, 1 / dl);
const cp = V.madd(a, d, w.r);
if (cp[1] >= 0 || (G.pitOpen && inPit(cp))) { w.ang -= cmd * 20 * dt; continue; }
const vel = velAt(b, cp);
let Fn = b.kW * Math.min(-cp[1], 0.05) - b.cW * vel[1]; if (Fn <= 0) continue;
Fn = Math.min(Fn, b.m * GRAV * 4); b.touch = true;
let fx = bf[0], fz = bf[2]; const fl = Math.hypot(fx, fz);
if (fl < 0.2) continue;
fx /= fl; fz /= fl;
const lx = -fz, lz = fx;
const vl = vel[0] * fx + vel[2] * fz, vt = vel[0] * lx + vel[2] * lz;
// DC motor: full force when stalled, none at top speed (back-EMF); with no stick it brakes a little
const Fm = w.Fmax * b.pw * b.hp.drv[w.side < 0 ? 0 : 1];
let Fx = clamp(Fm * (cmd - vl / b.vmax), -Fm * 1.1, Fm * 1.1);
let Fy = -vt * 900;
const lim = w.mu * Fn;
if (Math.hypot(Fx, Fy) > lim) {
// sliding: friction pulls against the tyre's slip. A motor pushing hard spins the tyre faster than the
// ground goes by, so most of the grip stays along the wheel - that is what lets skid-steer robots turn.
const sl = Fm > 0 ? clamp(Fx / Fm, -1.1, 1.1) * b.vmax : 0, sm = Math.hypot(sl, vt) || 1;
Fx = lim * sl / sm; Fy = -lim * vt / sm;
}
addF(b, [fx * Fx + lx * Fy, Fn, fz * Fx + lz * Fy], cp);
w.ang -= vl / w.r * dt;
if (Fm > 0) P += Math.abs(cmd) * b.wPer * (0.25 + 0.75 * Math.min(1, Math.abs(Fx) / Fm));
}
return P;
}
function pointInCol(p, c) { // -> [normal, depth] pushing the point out of the box, or null
const l = qrot(qconj(c.qw), V.sub(p, c.cw));
const dx = c.h[0] - Math.abs(l[0]); if (dx <= 0) return null;
const dy = c.h[1] - Math.abs(l[1]); if (dy <= 0) return null;
const dz = c.h[2] - Math.abs(l[2]); if (dz <= 0) return null;
let n;
if (dx <= dy && dx <= dz) n = [Math.sign(l[0]) || 1, 0, 0]; else if (dy <= dz) n = [0, Math.sign(l[1]) || 1, 0]; else n = [0, 0, Math.sign(l[2]) || 1];
return [qrot(c.qw, n), Math.min(dx, dy, dz)];
}
function pairContact(a, b, res) {
let best = 0, bp = null;
for (const c of b.cols) {
if (!c.solid) continue;
for (const pt of a.pts) {
const r = pointInCol(pt.p, c); if (!r) continue;
const [n, depth] = r, s = pt.p;
const vr = V.sub(velAt(a, s), velAt(b, s)), vn = V.dot(vr, n);
let Fn = 14000 * Math.min(depth, 0.04) - 260 * vn; if (Fn <= 0) continue;
const vt = V.madd(vr, n, -vn); let Ft = V.mul(vt, -300); const tl = V.len(Ft), mx = 0.5 * Fn; if (tl > mx) Ft = V.mul(Ft, mx / tl);
const F = V.madd(Ft, n, Fn); addF(a, F, s); addF(b, V.mul(F, -1), s);
if (-vn > best) { best = -vn; bp = s; }
res.any = true;
}
}
if (best > res.app) { res.app = best; res.pt = bp; }
}
function robotPairs() {
const bs = G.bots;
for (let i = 0; i < bs.length; i++) for (let j = i + 1; j < bs.length; j++) {
const a = bs[i], b = bs[j]; if (a.frozen || b.frozen) continue;
const key = a.id + ':' + b.id;
if (V.dist(a.p, b.p) > a.rad + b.rad + 0.1) { G.touching[key] = false; continue; }
const res = { any: false, app: 0, pt: null };
pairContact(a, b, res); pairContact(b, a, res);
if (res.any && !G.touching[key] && res.app > 1.4 && G.phase === 'fight' && (G.ramT[key] || 0) < G.clock) { // a ram: both robots take a little damage
const dmg = (res.app - 1.4) * 4; G.ramT[key] = G.clock + 0.6;
damage(a, dmg, res.pt, 'ram', b); damage(b, dmg, res.pt, 'ram', a);
sparks(res.pt, 6 + dmg | 0); snd('crash', Math.min(1, res.app / 4));
}
G.touching[key] = res.any;
}
}
function integrate(b, dt) {
b.v = V.madd(b.v, b.F, dt / b.m);
b.w = V.madd(b.w, invIw(b, b.T), dt);
b.w = V.mul(b.w, 1 - 0.4 * dt); b.v = V.mul(b.v, 1 - 0.02 * dt);
const wl = V.len(b.w); if (wl > 40) b.w = V.mul(b.w, 40 / wl);
const vl = V.len(b.v); if (vl > 25) b.v = V.mul(b.v, 25 / vl);
b.p = V.madd(b.p, b.v, dt);
const q = b.q, dq = qmul([b.w[0], b.w[1], b.w[2], 0], q);
b.q = qnorm([q[0] + dq[0] * 0.5 * dt, q[1] + dq[1] * 0.5 * dt, q[2] + dq[2] * 0.5 * dt, q[3] + dq[3] * 0.5 * dt]);
if (!isFinite(b.p[0] + b.p[1] + b.p[2] + b.q[0] + b.v[0] + b.w[0])) place(b, 0, 0, 0); // safety net
}
function physStep(dt) {
for (const b of G.bots) {
if (b.frozen) continue;
b.F = [0, -b.m * GRAV, 0]; b.T = [0, 0, 0]; b.touch = false;
const vy = b.v[1];
weaponStep(b, dt);
updArms(b); hull(b);
const P = wheels(b, dt);
for (const c of b.cols) { if (!c.solid && c.kind !== 'spin') continue; for (const g of c.g) groundPt(b, V.add(c.cw, qrot(c.qw, g)), c.kind === 'body' ? 0.35 : 0.3); }
for (const pt of b.pts) wallPt(b, pt.p);
if (b.touch && !b.wasTouch && vy < -3.6 && G.phase === 'fight') { // hard landing
const bu = upv(b); damage(b, (-vy - 3.6) * 6, V.madd(b.p, bu[1] > 0 ? [0, -1, 0] : [0, 1, 0], 0.05), 'fall', null); snd('crash', 0.7);
}
b.wasTouch = b.touch;
// battery
const used = P + (b.wepP || 0);
b.hp.bat = Math.max(0, b.hp.bat - used * dt / 3600);
b.pw = b.hp.bat > 0 ? 1 : 0.15;
}
robotPairs();
for (const b of G.bots) if (!b.frozen) spinHits(b);
for (const b of G.bots) if (!b.frozen) integrate(b, dt);
}
// ---------------- weapons ----------------
function selfRight(b) {
const bu = upv(b); let ax = V.cross(bu, [0, 1, 0]);
if (V.len(ax) < 0.15) ax = fwd(b);
ax = V.norm(ax);
b.w = V.add(V.mul(b.w, 0.3), V.mul(ax, 1 + 3 * Math.acos(clamp(bu[1], -1, 1)))); // spin just enough to land on the wheels
b.v[1] += 2.1; b.srCool = 1.3;
sparks(V.madd(b.p, bu, 0.08), 6); snd(b.wep.k === 'flip' ? 'flip' : 'clank', 0.7);
if (b.wep.k === 'flip') { b.wep.shots = Math.max(0, b.wep.shots - 1); b.wep.st = 0.08; }
if (b.wep.k === 'lift') b.wep.a = 0.9;
if (b.wep.k === 'hammer') b.wep.a = b.wep.aHit;
}
function weaponStep(b, dt) {
const W = b.wep, s = b.s;
W.cool = Math.max(0, W.cool - dt); b.srCool = Math.max(0, b.srCool - dt);
for (const k in b.hitCool) b.hitCool[k] -= dt;
let press = b.press; b.press = false;
const hold = b.input.f && !b.out && G.phase !== 'count';
if (b.out || G.phase === 'count') press = false;
const bu = upv(b), upside = bu[1] < 0.35, ok = b.hp.wep > 0 && !b.out;
b.wepP = 0;
if (press && upside && s.sr && b.srCool <= 0 && b.wasTouch &&!(bu[1] < -0.5 && s.inv) && (W.k !== 'flip' || W.shots > 0)) { selfRight(b); press = false; }
switch (W.k) {
case 'lift': { const t = hold && ok ? 0.95 : W.a0; W.a += clamp(t - W.a, -1.8 * dt, 1.8 * dt); if (hold) b.wepP = s.we.w * 0.5; break; }
case 'flip':
if (press && ok && !upside && W.cool <= 0 && W.shots > 0) fireFlip(b);
if (W.st > 0) { W.a = Math.min(1.25, W.a + dt * 16); W.st -= dt; } else W.a = Math.max(0, W.a - dt * 2.5);
break;
case 'hammer':
if (press && ok && W.st === 0 && W.cool <= 0) W.st = 1;
if (W.st === 1) { W.a -= dt * 13; b.wepP = s.we.w; if (W.a <= W.aHit) { W.a = W.aHit; hammerHit(b); W.st = 2; } }
else if (W.st === 2) { W.a += dt * 3.5; if (W.a >= W.a0) { W.a = W.a0; W.st = 0; W.cool = 0.2; } }
break;
case 'bar': case 'drum': {
if (press && b.ctrl !== 'ai') W.on = !W.on;
if (b.out) W.on = false;
const omMax = W.omMax * (b.hp.wep > 0 ? 0.4 + 0.6 * b.hp.wep : 0) * (b.pw < 1 ? 0.3 : 1);
if (W.on && omMax > 0 && W.om < omMax) { // spin up: torque limited at low speed, power limited at high speed
const Pw = s.we.w * s.vs, Tq = Math.min(Pw / Math.max(W.om, 1), Pw / (W.omMax * 0.12));
W.om = Math.min(omMax, W.om + Tq / s.we.I * dt); b.wepP = Pw;
} else if (W.on) b.wepP = s.we.w * 0.1;
W.om -= W.om * (W.on ? 0.05 : 0.35) * dt;
W.ang = (W.ang + W.om * dt) % (Math.PI * 2);
break; }
}
}
function others(b) { return G.bots.filter(o => o !== b && !o.frozen); }
function toLocal(b, p) { return qrot(qconj(b.q), V.sub(p, b.p)); }
function inFlipZone(b, o) {
let best = null, bd = 9;
for (const pt of o.pts) {
const l = toLocal(b, pt.p);
if (l[0] > b.L / 2 - 0.3 && l[0] < b.L / 2 + 0.24 && Math.abs(l[2]) < b.W / 2 + 0.06 && l[1] > -b.H / 2 - 0.06 && l[1] < b.H / 2 + 0.26) {
const dd = Math.abs(l[0] - b.L / 2) + Math.abs(l[1] - b.H / 2); if (dd < bd) { bd = dd; best = pt.p; }
}
}
return best;
}
function fireFlip(b) {
const W = b.wep; W.st = 0.08; W.shots--; W.cool = 0.8; snd('flip', 1);
const f = fwd(b), ff = V.norm([f[0], 0, f[2]]), dir = V.norm(V.add(V.mul(ff, 0.45), [0, 0.9, 0]));
for (const o of others(b)) {
const pt = inFlipZone(b, o); if (!pt) continue;
const J = o.m * 4.8 * Math.max(0.4, b.pw); // about a 1 m throw, like a real featherweight flipper
addJ(o, V.mul(dir, J), pt); addJ(b, V.mul(dir, -J * 0.3), V.madd(b.p, f, b.L / 2));
damage(o, 3, pt, 'flip', b); sparks(pt, 8);
}
}
function hammerHit(b) {
const W = b.wep, head = V.add(b.p, qrot(b.q, W.col.c));
let hit = false;
for (const o of others(b)) {
if (V.dist(o.p, head) > o.rad + 0.2) continue;
let pt = null;
for (const q of o.pts) if (V.dist(q.p, head) < 0.15) { pt = q.p; break; }
if (!pt) continue;
hit = true;
damage(o, 17 * Math.max(0.4, b.pw), pt, 'hammer', b);
addJ(o, [0, -16, 0], pt); sparks(pt, 14); snd('hit', 1);
}
if (!hit) { sparks(V.add(head, [0, -0.04, 0]), 5); snd('clank', 0.8); }
W.cool = 0.15;
}
function spinHits(b) {
const W = b.wep;
if ((W.k !== 'bar' && W.k !== 'drum') || W.om < W.omMax * 0.25 || b.hp.wep <= 0) return;
const hub = V.add(b.p, qrot(b.q, W.hub)), bu = upv(b), bf = fwd(b), I = b.s.we.I, bar = W.k === 'bar';
// the bar also bites the arena wall
if (bar && (b.hitCool.wall || 0) <= 0) for (const ax of [0, 2]) if (Math.abs(hub[ax]) + W.R * 0.95 > A) {
const n = [0, 0, 0]; n[ax] = hub[ax] > 0 ? -1 : 1; const at = V.madd(hub, n, -W.R);
addJ(b, V.mul(n, 0.12 * W.om * I * 4), at); W.om *= 0.85; sparks(at, 10); snd('clank', 0.6); b.hitCool.wall = 0.3;
}
for (const o of others(b)) {
if ((b.hitCool[o.id] || 0) > 0 || V.dist(o.p, hub) > o.rad + W.R + 0.1) continue;
for (const pt of o.pts) {
const l = toLocal(b, pt.p), h = V.sub(l, W.hub);
let hit;
if (bar) { const r = Math.hypot(h[0], h[2]); hit = r < W.R + 0.015 && r > 0.03 && Math.abs(h[1]) < 0.06 && h[0] > -0.06; }
else { const r = Math.hypot(h[0], h[1]); hit = r < W.R + 0.02 && Math.abs(h[2]) < W.wd / 2 + 0.01; }
if (!hit) continue;
const E = 0.5 * I * W.om * W.om, fr = bar ? 0.45 : 0.35, Et = E * fr;
W.om *= Math.sqrt(1 - fr);
let dir;
if (bar) { const rad = V.norm([pt.p[0] - hub[0], 0, pt.p[2] - hub[2]]), tan = V.cross(bu, rad); dir = V.norm(V.add(V.add(V.mul(tan, 0.75), V.mul(rad, 0.6)), [0, 0.2, 0])); }
else dir = V.norm(V.add(V.mul(V.norm([bf[0], 0, bf[2]]), 0.5), [0, 0.85, 0]));
const J = Math.sqrt(2 * o.m * Et * (bar ? 0.1 : 0.14));
addJ(o, V.mul(dir, J), pt.p); addJ(b, V.mul(dir, -J * 0.45), hub);
damage(o, Et / (bar ? 25 : 20), pt.p, 'spin', b);
sparks(pt.p, 10 + Et / 40 | 0); snd('hit', Math.min(1, Et / 600 + 0.3));
b.hitCool[o.id] = 0.22;
break;
}
}
}
// ---------------- damage ----------------
const SIDES = ['front', 'back', 'left side', 'right side', 'top', 'bottom'];
function damage(o, amt, pt, kind, by) {
if (!pt || o.out || G.phase !== 'fight' || amt <= 0) return;
const l = toLocal(o, pt), nx = l[0] / (o.L / 2), ny = kind === 'ram' ? 0 : l[1] / (o.H / 2), nz = l[2] / (o.W / 2);
const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
const side = ax >= ay && ax >= az ? (nx > 0 ? 0 : 1) : az >= ay ? (nz < 0 ? 2 : 3) : (ny > 0 ? 4 : 5);
if (kind === 'spin') amt *= o.s.ar.spin;
const h = o.hp; let over = amt;
if (h.arm[side] > 0) { const t = Math.min(h.arm[side], over); h.arm[side] -= t; over = over - t + t * 0.08; }
if (over > 0) {
h.frame -= over;
const r = Math.random(), comp = side === 2 ? 0 : side === 3 ? 1 : side === 0 ? 2 : r < 0.4 ? 2 : r < 0.7 ? 0 : 1;
if (comp === 2) h.wep = Math.max(0, h.wep - over / 45); else h.drv[comp] = Math.max(0, h.drv[comp] - over / 40);
}
if (by && by !== o) by.dealt += amt;
o.flash = 0.15;
if (h.frame <= 0) ko(o, 'destroyed');
}
function healthFrac(b) {
const h = b.hp; let a = 0, am = 0; for (let i = 0; i < 6; i++) { a += Math.max(0, h.arm[i]); am += h.armMax[i]; }
return clamp(0.4 * a / am + 0.6 * Math.max(0, h.frame) / h.frameMax, 0, 1);
}
// ---------------- computer drivers ----------------
function aiThink(b) {
const ai = b.ai;
if (b.out || G.phase !== 'fight') { b.input = { x: 0, y: 0, f: false }; return; }
const foes = G.bots.filter(o => o !== b && !o.out && !o.frozen);
if (!foes.length) { b.input = { x: 0, y: 0, f: false }; return; }
let t = foes[0]; for (const o of foes) if (V.dist(o.p, b.p) < V.dist(t.p, b.p)) t = o;
const lv = ai.lvl, maxT = [0.5, 0.75, 0.95, 1][lv];
const bf = fwd(b), hd = Math.atan2(bf[2], bf[0]);
let aim = lv >= 2 ? V.madd(t.p, t.v, 0.25) : t.p.slice();
if (lv >= 1 && G.pitOpen) { // line up behind the target to shove it into the pit
const tx = PIT.x - t.p[0], tz = PIT.z - t.p[2], d = Math.hypot(tx, tz);
if (d < 1.6 && d > 0.01) aim = V.madd(t.p, [tx / d, 0, tz / d], -0.3);
}
let dx = aim[0] - b.p[0], dz = aim[2] - b.p[2];
if (G.pitOpen) { const px = b.p[0] - PIT.x, pz = b.p[2] - PIT.z, pd = Math.hypot(px, pz) || 0.01; if (pd < 1.05) { const k = (1.05 - pd) * 4; dx += px / pd * k; dz += pz / pd * k; } }
let err = wrap(Math.atan2(dz, dx) - hd);
if (lv === 0) err += Math.sin(G.clock * 1.3 + b.id) * 0.4;
let x = clamp(err * 2.2, -1, 1), y = Math.max(0, Math.cos(err)) * maxT; if (Math.abs(err) > 1.3) y = 0.1 * maxT;
const sp = Math.hypot(b.v[0], b.v[2]);
if (ai.back > 0) { ai.back -= FIX; y = -0.8; x = 0.5; }
else { if (y > 0.35 && sp < 0.1) ai.stuck = (ai.stuck || 0) + FIX; else ai.stuck = 0; if (ai.stuck > 1.1) { ai.back = 0.7; ai.stuck = 0; } }
let f = false; const d = V.dist(t.p, b.p), k = b.wep.k, front = Math.abs(err) < 0.5;
ai.fc = (ai.fc || 0) - FIX;
if (k === 'bar' || k === 'drum') b.wep.on = G.clock - G.fightStart > [2.5, 1.2, 0.4, 0][lv];
else if (k === 'lift') f = d < b.L / 2 + t.L / 2 + 0.25 && front;
else if (k === 'flip') { if (ai.fc <= 0 && inFlipZone(b, t)) { f = true; ai.fc = [1.3, 0.8, 0.4, 0.25][lv]; } }
else if (k === 'hammer') { if (ai.fc <= 0 && d < 0.65 && front) { f = true; ai.fc = [1.5, 0.9, 0.5, 0.3][lv]; } }
if (upv(b)[1] < 0.35) { ai.sr = (ai.sr || 0) - FIX; if (ai.sr <= 0) { f = true; ai.sr = 1.6; } }
b.input = { x, y, f };
}
// ---------------- input ----------------
const IN = { jx: 0, jy: 0, fire: false, latch: false, tid: null, ox: 0, oy: 0, keys: {} };
function readKeys() {
const k = IN.keys; let x = 0, y = 0;
if (k.ArrowLeft || k.KeyA) x -= 1; if (k.ArrowRight || k.KeyD) x += 1;
if (k.ArrowUp || k.KeyW) y += 1; if (k.ArrowDown || k.KeyS) y -= 1;
return [x, y];
}
function humanInput(b) {
let jx = IN.jx, jy = IN.jy; const [kx, ky] = readKeys(); if (kx || ky) { jx = kx; jy = ky; }
const m = Math.min(1, Math.hypot(jx, jy));
let x = 0, y = 0;
if (m > 0.12) {
if (SAVE.opt.easy && SAVE.opt.cam === 'arena') {
// easy steering: the robot goes where you point on the screen
const dx = G.camR[0] * jx + G.camF[0] * jy, dz = G.camR[2] * jx + G.camF[2] * jy;
const bf = fwd(b), hd = Math.atan2(bf[2], bf[0]); const err = wrap(Math.atan2(dz, dx) - hd);
if (Math.abs(err) < 2.2) { x = clamp(err * 2.4, -1, 1); y = Math.abs(err) > 1.2 ? 0 : m * Math.cos(err); }
else { const e2 = wrap(err - Math.PI); x = clamp(e2 * 2.4, -1, 1); y = Math.abs(e2) > 1.2 ? 0 : -m * Math.cos(e2); }
} else { x = jx * 0.85; y = jy; }
}
const f = IN.fire || IN.latch || !!(IN.keys.Space || IN.keys.KeyJ || IN.keys.Enter);
IN.latch = false;
return { x, y, f };
}
addEventListener('keydown', e => {
if (e.target && e.target.tagName === 'INPUT') return;
IN.keys[e.code] = true; if (e.code === 'Space' || e.code === 'KeyJ') IN.latch = true;
if (e.code === 'KeyC' && inFight()) toggleCam();
if ((e.code === 'Escape' || e.code === 'KeyP') && inFight()) pauseGame();
if (e.code.startsWith('Arrow') || e.code === 'Space') e.preventDefault();
});
addEventListener('keyup', e => { IN.keys[e.code] = false; });
const joy = $('joy'), joyKnob = joy.firstElementChild;
const cvEl = $('gl');
function onTouchStart(e) {
ac();
if (!inFight()) { if (G.mode === 'garage') { const t = e.changedTouches ? e.changedTouches[0] : e; G.drag = { x: t.clientX, yaw: G.gYaw }; } return; }
const list = e.changedTouches || [e];
for (const t of list) {
if (IN.tid !== null) break;
if (t.clientX > innerWidth * 0.62) continue;
IN.tid = t.identifier != null ? t.identifier : 'm'; IN.ox = t.clientX; IN.oy = t.clientY;
joy.style.left = t.clientX + 'px'; joy.style.top = t.clientY + 'px'; joy.classList.remove('hide'); $('joyHint').classList.add('hide');
joyKnob.style.transform = '';
}
e.preventDefault();
}
function onTouchMove(e) {
const list = e.changedTouches || [e];
for (const t of list) {
if (G.drag && !inFight()) { G.gYaw = G.drag.yaw + (t.clientX - G.drag.x) * 0.01; continue; }
if ((t.identifier != null ? t.identifier : 'm') !== IN.tid) continue;
let dx = t.clientX - IN.ox, dy = t.clientY - IN.oy; const l = Math.hypot(dx, dy), R = 60;
if (l > R) { dx *= R / l; dy *= R / l; }
IN.jx = dx / R; IN.jy = -dy / R; joyKnob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
}
if (inFight()) e.preventDefault();
}
function onTouchEnd(e) {
G.drag = null;
const list = e.changedTouches || [e];
for (const t of list) if ((t.identifier != null ? t.identifier : 'm') === IN.tid) { IN.tid = null; IN.jx = IN.jy = 0; joy.classList.add('hide'); }
}
cvEl.addEventListener('touchstart', onTouchStart, { passive: false });
cvEl.addEventListener('touchmove', onTouchMove, { passive: false });
cvEl.addEventListener('touchend', onTouchEnd); cvEl.addEventListener('touchcancel', onTouchEnd);
$('hud').addEventListener('touchstart', e => { if (e.target === $('hud') || e.target.closest('#joyHint')) onTouchStart(e); }, { passive: false });
$('hud').addEventListener('touchmove', onTouchMove, { passive: false });
$('hud').addEventListener('touchend', onTouchEnd);
let mouseDown = false;
cvEl.addEventListener('mousedown', e => { mouseDown = true; onTouchStart(e); });
addEventListener('mousemove', e => { if (mouseDown) onTouchMove(e); });
addEventListener('mouseup', e => { if (mouseDown) { mouseDown = false; onTouchEnd(e); } });
const fireBtn = $('fire');
const fireDown = e => { e.preventDefault(); ac(); IN.fire = true; IN.latch = true; fireBtn.classList.add('on'); };
const fireUp = e => { e.preventDefault(); IN.fire = false; fireBtn.classList.remove('on'); };
fireBtn.addEventListener('touchstart', fireDown, { passive: false }); fireBtn.addEventListener('touchend', fireUp, { passive: false });
fireBtn.addEventListener('mousedown', fireDown); fireBtn.addEventListener('mouseup', fireUp); fireBtn.addEventListener('mouseleave', fireUp);
// ---------------- sound ----------------
const AU = { ctx: null };
function ac() {
if (!AU.ctx) {
try {
const C = window.AudioContext || window.webkitAudioContext; if (!C) return;
const c = AU.ctx = new C(); AU.master = c.createGain(); AU.master.gain.value = 0.5; AU.master.connect(c.destination);
const nb = AU.nb = c.createBuffer(1, c.sampleRate, c.sampleRate), d = nb.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
AU.mo = c.createOscillator(); AU.mo.type = 'sawtooth'; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 380;
AU.mg = c.createGain(); AU.mg.gain.value = 0; AU.mo.connect(lp); lp.connect(AU.mg); AU.mg.connect(AU.master); AU.mo.start();
AU.so = c.createOscillator(); AU.so.type = 'triangle'; AU.sg = c.createGain(); AU.sg.gain.value = 0; AU.so.connect(AU.sg); AU.sg.connect(AU.master); AU.so.start();
} catch (e) { AU.ctx = null; return; }
}
if (AU.ctx.state === 'suspended') AU.ctx.resume();
}
function snd(k, v) {
if (!SAVE.opt.snd || !AU.ctx || G.guestQuiet) return;
v = v == null ? 1 : v;
const c = AU.ctx, t = c.currentTime, g = c.createGain(); g.connect(AU.master);
if (k === 'hit' || k === 'flip' || k === 'crash' || k === 'clank') {
const s = c.createBufferSource(); s.buffer = AU.nb; const f = c.createBiquadFilter();
f.type = k === 'flip' ? 'highpass' : 'bandpass'; f.frequency.value = { hit: 1800, flip: 2500, crash: 500, clank: 1100 }[k];
const dur = { hit: 0.22, flip: 0.4, crash: 0.25, clank: 0.12 }[k];
s.connect(f); f.connect(g); g.gain.setValueAtTime(0.8 * v, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); s.start(t); s.stop(t + dur + 0.02);
if (k === 'hit' || k === 'clank') { const o = c.createOscillator(), og = c.createGain(); o.type = 'square'; o.frequency.setValueAtTime(k === 'hit' ? 950 : 600, t); o.frequency.exponentialRampToValueAtTime(200, t + 0.15);
og.gain.setValueAtTime(0.18 * v, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.18); o.connect(og); og.connect(AU.master); o.start(t); o.stop(t + 0.2); }
return;
}
const o = c.createOscillator(); o.connect(g);
const tone = { beep: ['sine', 660, 660, 0.15], go: ['square', 880, 880, 0.45], buzz: ['sawtooth', 140, 90, 0.9], pit: ['sawtooth', 500, 80, 1.0], coin: ['triangle', 990, 1480, 0.25], ko: ['square', 400, 60, 0.7] }[k];
if (!tone) return;
o.type = tone[0]; o.frequency.setValueAtTime(tone[1], t); o.frequency.exponentialRampToValueAtTime(tone[2], t + tone[3]);
g.gain.setValueAtTime(0.25 * v, t); g.gain.exponentialRampToValueAtTime(0.001, t + tone[3]); o.start(t); o.stop(t + tone[3] + 0.02);
}
function soundLoop() {
if (!AU.ctx) return;
const b = G.me, on = SAVE.opt.snd && b && !b.out && (inFight() || G.mode === 'garage') && !G.paused;
const sp = b ? Math.hypot(b.v[0], b.v[2]) : 0, c = b ? (Math.abs(b.cmd[0]) + Math.abs(b.cmd[1])) / 2 : 0;
const t = AU.ctx.currentTime;
AU.mg.gain.setTargetAtTime(on ? 0.03 + 0.09 * c : 0, t, 0.05); AU.mo.frequency.setTargetAtTime(45 + sp * 55 + c * 20, t, 0.05);
const om = b && (b.wep.k === 'bar' || b.wep.k === 'drum') ? b.wep.om : 0;
AU.sg.gain.setTargetAtTime(on && om > 5 ? 0.06 : 0, t, 0.08); AU.so.frequency.setTargetAtTime(60 + om * 0.9, t, 0.05);
}
// ---------------- renderer ----------------
const gl = cvEl.getContext('webgl', { antialias: true }) || cvEl.getContext('experimental-webgl');
if (!gl) document.body.insertAdjacentHTML('beforeend', '<div class="screen"><h2>Sorry, this tablet cannot show 3D (no WebGL).</h2></div>');
const VS = 'attribute vec3 aP;attribute vec3 aN;uniform mat4 uVP;uniform mat4 uM;varying vec3 vN;varying vec3 vW;' +
'void main(){vec4 w=uM*vec4(aP,1.0);vW=w.xyz;vN=mat3(uM)*aN;gl_Position=uVP*w;}';
const FS = '#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n' +
'varying vec3 vN;varying vec3 vW;uniform vec3 uC;uniform float uE;uniform float uA;uniform float uF;uniform vec3 uCam;uniform vec3 uL;' +
'void main(){vec3 n=normalize(vN);vec3 c=uC;' +
'if(uF>0.5){vec2 g=abs(fract(vW.xz*2.0)-0.5);float ln=step(0.47,max(g.x,g.y));vec2 cell=floor(vW.xz*2.0);float r=fract(sin(dot(cell,vec2(12.9898,78.233)))*43758.5453);c*=(0.9+0.1*r)*(1.0-0.3*ln);}' +
'float d=max(dot(n,uL),0.0);vec3 v=normalize(uCam-vW);vec3 h=normalize(uL+v);float s=pow(max(dot(n,h),0.0),40.0)*0.35;' +
'vec3 col=c*(0.4+0.6*d)+vec3(s)*(1.0-uE);col=mix(col,c,uE);gl_FragColor=vec4(col,uA);}';
let PR, U = {};
function initGL() {
const sh = (t, s) => { const x = gl.createShader(t); gl.shaderSource(x, s); gl.compileShader(x); if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(x)); return x; };
PR = gl.createProgram(); gl.attachShader(PR, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(PR, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(PR); gl.useProgram(PR);
for (const n of ['uVP', 'uM', 'uC', 'uE', 'uA', 'uF', 'uCam', 'uL']) U[n] = gl.getUniformLocation(PR, n);
U.aP = gl.getAttribLocation(PR, 'aP'); U.aN = gl.getAttribLocation(PR, 'aN');
gl.enableVertexAttribArray(U.aP); gl.enableVertexAttribArray(U.aN);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform3fv(U.uL, V.norm([0.45, 1, 0.3]));
}
function mkMesh(P, N, I) {
const vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(P), gl.STATIC_DRAW);
const nb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, nb); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(N), gl.STATIC_DRAW);
const ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(I), gl.STATIC_DRAW);
return { vb, nb, ib, n: I.length };
}
function boxGeo() {
const P = [], N = [], I = [];
for (const n of [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]) {
const u = Math.abs(n[1]) > 0.5 ? [1, 0, 0] : [0, 1, 0], v = V.cross(n, u), b = P.length / 3;
for (const [su, sv] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { for (let i = 0; i < 3; i++) P.push(0.5 * (n[i] + su * u[i] + sv * v[i])); N.push(n[0], n[1], n[2]); }
I.push(b, b + 1, b + 2, b, b + 2, b + 3);
}
return mkMesh(P, N, I);
}
function cylGeo(seg) { // radius 0.5, along z from -0.5 to 0.5
const P = [], N = [], I = [];
for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2, c = Math.cos(a), s = Math.sin(a); P.push(c * .5, s * .5, -.5, c * .5, s * .5, .5); N.push(c, s, 0, c, s, 0); }
for (let i = 0; i < seg; i++) { const b = i * 2; I.push(b, b + 1, b + 3, b, b + 3, b + 2); }
for (const z of [-.5, .5]) {
const c0 = P.length / 3; P.push(0, 0, z); N.push(0, 0, Math.sign(z));
for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2; P.push(Math.cos(a) * .5, Math.sin(a) * .5, z); N.push(0, 0, Math.sign(z)); }
for (let i = 0; i < seg; i++) I.push(c0, c0 + 1 + i, c0 + 2 + i);
}
return mkMesh(P, N, I);
}
let MB, MC;
function m4mul(a, b) { const o = new Float32Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]; return o; }
function m4trs(p, q, s) {
const [x, y, z, w] = q, o = new Float32Array(16);
o[0] = (1 - 2 * (y * y + z * z)) * s[0]; o[1] = 2 * (x * y + w * z) * s[0]; o[2] = 2 * (x * z - w * y) * s[0];
o[4] = 2 * (x * y - w * z) * s[1]; o[5] = (1 - 2 * (x * x + z * z)) * s[1]; o[6] = 2 * (y * z + w * x) * s[1];
o[8] = 2 * (x * z + w * y) * s[2]; o[9] = 2 * (y * z - w * x) * s[2]; o[10] = (1 - 2 * (x * x + y * y)) * s[2];
o[12] = p[0]; o[13] = p[1]; o[14] = p[2]; o[15] = 1; return o;
}
function m4persp(fov, asp, n, f) { const t = 1 / Math.tan(fov / 2), m = new Float32Array(16); m[0] = t / asp; m[5] = t; m[10] = (f + n) / (n - f); m[11] = -1; m[14] = 2 * f * n / (n - f); return m; }
function m4look(e, c, u) {
const z = V.norm(V.sub(e, c)), x = V.norm(V.cross(u, z)), y = V.cross(z, x), m = new Float32Array(16);
m[0] = x[0]; m[4] = x[1]; m[8] = x[2]; m[1] = y[0]; m[5] = y[1]; m[9] = y[2]; m[2] = z[0]; m[6] = z[1]; m[10] = z[2];
m[12] = -V.dot(x, e); m[13] = -V.dot(y, e); m[14] = -V.dot(z, e); m[15] = 1; return m;
}
function draw(mesh, M, col, a, e, fl) {
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vb); gl.vertexAttribPointer(U.aP, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.nb); gl.vertexAttribPointer(U.aN, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, mesh.ib);
gl.uniformMatrix4fv(U.uM, false, M); gl.uniform3fv(U.uC, col); gl.uniform1f(U.uA, a == null ? 1 : a); gl.uniform1f(U.uE, e || 0); gl.uniform1f(U.uF, fl || 0);
gl.drawElements(gl.TRIANGLES, mesh.n, gl.UNSIGNED_SHORT, 0);
}
const dp = (Mb, mesh, p, q, s, col, a, e) => draw(mesh, m4mul(Mb, m4trs(p, q || QI, s)), col, a, e, 0);
const C = { tire: [.11, .11, .12], hub: [.72, .73, .75], steel: [.55, .58, .62], dark: [.2, .21, .23], black: [.04, .04, .05], yellow: [.95, .75, .1], red: [.9, .15, .1], green: [.2, .9, .3], spark: [1, .8, .3] };
function drawBot(b) {
const Mb = m4trs(b.p, b.q, [1, 1, 1]), s = b.s, L = b.L, W = b.W, H = b.H;
const hf = healthFrac(b), dark = 0.55 + 0.45 * hf;
const flash = b.flash > 0 ? 0.5 : 0;
const armC = mix3(V.mul(hex2rgb(s.ar.col || '#9aa'), dark), [1, 1, 1], flash), paint = V.mul(hex2rgb(b.col), 0.6 + 0.4 * hf);
dp(Mb, MB, [0, 0, 0], QI, [L, H, W], armC);
dp(Mb, MB, [0, H / 2 + 0.002, 0], QI, [L * 0.82, 0.006, W * 0.72], paint);
for (const z of [-1, 1]) dp(Mb, MB, [0, 0, z * (W / 2 + 0.002)], QI, [L * 0.84, H * 0.38, 0.004], paint);
dp(Mb, MB, [-L / 2 - 0.002, 0, 0], QI, [0.004, H * 0.5, W * 0.5], C.dark);
// armor that is gone shows the dark inside of the frame
const h = b.hp;
if (h.arm[4] <= 0) dp(Mb, MB, [0, H / 2 + 0.006, 0], QI, [L * 0.5, 0.004, W * 0.4], C.black);
if (h.arm[0] <= 0) dp(Mb, MB, [L / 2 + 0.003, 0, 0], QI, [0.004, H * 0.6, W * 0.6], C.black);
dp(Mb, MB, [-L * 0.36, H / 2 + 0.05, W * 0.3], QI, [0.006, 0.1, 0.006], C.black);
dp(Mb, MB, [-L * 0.36, H / 2 + 0.1, W * 0.3], QI, [0.014, 0.014, 0.014], C.red, 1, 1);
const led = b.out ? C.red : (G.clock * 2 % 1 < 0.6 ? C.green : [0.05, 0.3, 0.1]);
dp(Mb, MB, [-L * 0.3, H / 2 + 0.007, -W * 0.28], QI, [0.025, 0.008, 0.025], led, 1, 1);
for (const w of b.wheels) {
const q = qaxis([0, 0, 1], w.ang);
dp(Mb, MC, w.c, q, [w.r * 2, w.r * 2, w.wd], C.tire);
dp(Mb, MC, w.c, q, [w.r * 1.15, w.r * 1.15, w.wd * 1.06], C.hub);
dp(Mb, MB, w.c, q, [w.r * 1.5, w.r * 0.22, w.wd * 1.1], C.dark);
}
for (const v of b.vis) dp(Mb, MB, v.c, v.q, v.s, C.steel);
const Wp = b.wep;
switch (Wp.k) {
case 'lift': case 'flip': {
const q = qaxis([0, 0, 1], Wp.a), dir = [Math.cos(Wp.a), Math.sin(Wp.a), 0];
dp(Mb, MB, V.madd(Wp.piv, dir, Wp.len / 2), q, [Wp.len, 0.012, Wp.hw * 2], Wp.k === 'flip' ? paint : C.steel);
if (Wp.k === 'flip') dp(Mb, MB, V.madd(Wp.piv, dir, Wp.len * 0.55), q, [Wp.len * 0.3, 0.016, Wp.hw * 1.2], C.steel);
break; }
case 'hammer': {
const q = qaxis([0, 0, 1], Wp.a), dir = [Math.cos(Wp.a), Math.sin(Wp.a), 0];
dp(Mb, MB, Wp.piv, QI, [0.05, 0.04, 0.08], C.dark);
dp(Mb, MB, V.madd(Wp.piv, dir, Wp.len / 2), q, [Wp.len, 0.025, 0.03], C.steel);
dp(Mb, MB, Wp.col.c, q, [0.07, 0.08, 0.13], C.dark);
dp(Mb, MB, V.madd(Wp.col.c, [-Math.sin(Wp.a), Math.cos(Wp.a), 0], -0.05), q, [0.03, 0.03, 0.15], C.steel);
break; }
case 'bar': {
const q = qaxis([0, 1, 0], Wp.ang), up = qaxis([1, 0, 0], Math.PI / 2);
dp(Mb, MB, [Wp.hub[0], Wp.hub[1] - 0.02, 0], QI, [0.12, 0.02, 0.1], C.dark);
dp(Mb, MC, Wp.hub, up, [0.08, 0.08, 0.04], C.dark);
dp(Mb, MB, Wp.hub, q, [Wp.R * 2, 0.025, 0.06], C.steel);
for (const sg of [-1, 1]) dp(Mb, MB, V.add(Wp.hub, qrot(q, [sg * Wp.R * 0.9, 0, sg * 0.03])), q, [0.05, 0.03, 0.02], [.85, .86, .9]);
if (Wp.om > Wp.omMax * 0.3) dp(Mb, MC, Wp.hub, up, [Wp.R * 2, Wp.R * 2, 0.01], [.8, .82, .85], 0.25);
break; }
case 'drum': {
const q = qaxis([0, 0, 1], -Wp.ang);
for (const sg of [-1, 1]) dp(Mb, MB, [L / 2 + 0.03, Wp.hub[1], sg * (Wp.wd / 2 + 0.012)], QI, [0.13, 0.03, 0.018], C.dark);
dp(Mb, MC, Wp.hub, q, [Wp.R * 2, Wp.R * 2, Wp.wd], C.steel);
for (const sg of [-1, 1]) dp(Mb, MB, V.add(Wp.hub, qrot(q, [0, sg * Wp.R, 0])), q, [0.025, 0.03, Wp.wd * 0.6], [.85, .86, .9]);
break; }
}
if (s.ex.id === 'srim') { dp(Mb, MB, [-L / 2 + 0.03, H / 2 + 0.03, 0], QI, [0.02, 0.06, W * 0.7], C.steel); }
if (s.ex.id === 'top') dp(Mb, MB, [L * 0.05, H / 2 + 0.008, 0], QI, [L * 0.5, 0.01, W * 0.5], C.dark);
}
function drawArena() {
draw(MB, m4trs([0, -0.05, 0], QI, [2 * A + 0.3, 0.1, 2 * A + 0.3]), [.46, .48, .5], 1, 0, 1);
draw(MB, m4trs([0, -0.12, 0], QI, [40, 0.02, 40]), [.09, .1, .13]);
for (const [x, z, sx, sz] of [[0, -A - 0.06, 2 * A + 0.24, 0.12], [0, A + 0.06, 2 * A + 0.24, 0.12], [-A - 0.06, 0, 0.12, 2 * A], [A + 0.06, 0, 0.12, 2 * A]]) {
draw(MB, m4trs([x, 0.12, z], QI, [sx, 0.24, sz]), [.22, .24, .28]);
draw(MB, m4trs([x, 0.245, z], QI, [sx, 0.012, sz]), C.yellow);
}
for (const x of [-1, 1]) for (const z of [-1, 1]) draw(MB, m4trs([x * (A + 0.06), 0.8, z * (A + 0.06)], QI, [0.14, 1.6, 0.14]), [.15, .16, .19]);
// the pit
if (G.pitOpen || G.pitLid < 1) {
draw(MB, m4trs([PIT.x, -0.3, PIT.z], QI, [PIT.h * 2, 0.604, PIT.h * 2]), [.02, .02, .03]);
for (const [dx, dz, sx, sz] of [[0, -1, 2, 0.08], [0, 1, 2, 0.08], [-1, 0, 0.08, 2], [1, 0, 0.08, 2]])
draw(MB, m4trs([PIT.x + dx * PIT.h, 0.004, PIT.z + dz * PIT.h], QI, [sx * PIT.h + 0.04, 0.008, sz * PIT.h + 0.04]), C.red, 1, 0.6);
}
if (G.pitLid > 0) {
const y = 0.004 - (1 - G.pitLid) * 0.25;
draw(MB, m4trs([PIT.x, y, PIT.z], QI, [PIT.h * 2 * G.pitLid + 0.001, 0.008, PIT.h * 2]), C.yellow);
for (let i = -2; i <= 2; i++) draw(MB, m4trs([PIT.x + i * PIT.h * 0.38 * G.pitLid, y + 0.002, PIT.z], qaxis([0, 1, 0], 0.7), [0.05, 0.008, PIT.h * 1.2]), C.black);
}
}
function drawGlass() {
gl.depthMask(false);
for (const [x, z, sx, sz] of [[0, -A - 0.06, 2 * A + 0.12, 0.02], [0, A + 0.06, 2 * A + 0.12, 0.02], [-A - 0.06, 0, 0.02, 2 * A], [A + 0.06, 0, 0.02, 2 * A]])
draw(MB, m4trs([x, 0.9, z], QI, [sx, 1.3, sz]), [.7, .85, 1], 0.09);
gl.depthMask(true);
}
function drawShadows() {
gl.depthMask(false);
for (const b of G.bots) {
if (b.frozen || b.p[1] < -0.05 || (G.pitOpen && inPit(b.p))) continue;
const f = fwd(b), yaw = Math.atan2(-f[2], f[0]), a = clamp(0.4 - b.p[1] * 0.5, 0.08, 0.4);
draw(MB, m4trs([b.p[0], 0.003, b.p[2]], qaxis([0, 1, 0], yaw), [b.L + 0.12, 0.002, b.W + 0.14]), [0, 0, 0], a, 1);
}
gl.depthMask(true);
}
// sparks + smoke
const FX = [];
function sparks(p, n) {
if (!p) return;
n = Math.min(n, SAVE.opt.q === 0 ? 6 : 24);
for (let i = 0; i < n && FX.length < 220; i++) FX.push({ p: p.slice(), v: [(Math.random() - .5) * 5, Math.random() * 3.5 + 0.5, (Math.random() - .5) * 5], t: 0.25 + Math.random() * 0.35, k: 0 });
if (G.host && G.net) NET.fxq.push([Math.round(p[0] * 100) / 100, Math.round(p[1] * 100) / 100, Math.round(p[2] * 100) / 100, n]);
}
function smoke(p) { if (FX.length < 220) FX.push({ p: p.slice(), v: [(Math.random() - .5) * .2, 0.5 + Math.random() * .3, (Math.random() - .5) * .2], t: 1.4, k: 1 }); }
function stepFX(dt) {
for (const f of FX) { f.t -= dt; if (f.k === 0) { f.v[1] -= 9.8 * dt; if (f.p[1] < 0.005 && f.v[1] < 0) { f.v[1] *= -0.3; f.v[0] *= 0.6; f.v[2] *= 0.6; } } f.p = V.madd(f.p, f.v, dt); }
for (let i = FX.length - 1; i >= 0; i--) if (FX[i].t <= 0) FX.splice(i, 1);
}
function drawFX() {
for (const f of FX) {
if (f.k === 0) draw(MB, m4trs(f.p, QI, [0.016, 0.016, 0.016]), mix3(C.spark, [1, .3, .05], 1 - f.t * 2), 1, 1);
else { const s = 0.06 + (1.4 - f.t) * 0.12; draw(MB, m4trs(f.p, QI, [s, s, s]), [.25, .25, .27], f.t * 0.4); }
}
}
let VPM = null;
function camera(asp) {
let eye, tgt;
if (G.mode === 'garage') {
const r = 1.25; tgt = [0, 0.08, 0]; eye = [Math.sin(G.gYaw) * r, 0.65, Math.cos(G.gYaw) * r];
} else if (SAVE.opt.cam === 'chase' && G.me && !G.me.frozen && !(G.me.out && G.me.why === 'pit')) {
const b = G.me, f = fwd(b); let ff = [f[0], 0, f[2]]; const fl = Math.hypot(ff[0], ff[2]); ff = fl > 0.2 ? [ff[0] / fl, 0, ff[2] / fl] : G.chaseF || [1, 0, 0];
G.chaseF = G.chaseF ? V.norm(V.add(V.mul(G.chaseF, 0.92), V.mul(ff, 0.08))) : ff;
const want = V.add(V.madd(b.p, G.chaseF, -1.35), [0, 0.8, 0]);
want[0] = clamp(want[0], -A - 1, A + 1); want[2] = clamp(want[2], -A - 1, A + 1);
G.camEye = G.camEye ? V.add(V.mul(G.camEye, 0.85), V.mul(want, 0.15)) : want;
eye = G.camEye; tgt = V.madd(b.p, G.chaseF, 0.6);
} else {
const act = G.bots.filter(b => !b.frozen && !(b.out && b.why === 'pit'));
let fc = [0, 0, 0], spread = 1.2;
if (act.length) { for (const b of act) fc = V.add(fc, b.p); fc = V.mul(fc, 1 / act.length); for (const b of act) spread = Math.max(spread, V.dist(b.p, fc)); }
fc = [clamp(fc[0], -1.4, 1.4) * 0.7, 0, clamp(fc[2], -1.4, 1.4) * 0.7];
let z = clamp(0.5 + spread * 0.3, 0.62, 1.15); if (asp < 1.25) z *= Math.min(1.7, 1.25 / asp);
G.camZ = G.camZ ? lerp(G.camZ, z, 0.04) : z; G.camFc = G.camFc ? V.add(V.mul(G.camFc, 0.94), V.mul(fc, 0.06)) : fc;
tgt = G.camFc; eye = V.add(tgt, [0, 3.7 * G.camZ, 3.9 * G.camZ]); G.camEye = null;
}
const cf = V.norm([tgt[0] - eye[0], 0, tgt[2] - eye[2]]); G.camF = cf; G.camR = V.cross(cf, [0, 1, 0]);
VPM = m4mul(m4persp(55 * Math.PI / 180, asp, 0.05, 60), m4look(eye, tgt, [0, 1, 0]));
gl.uniformMatrix4fv(U.uVP, false, VPM); gl.uniform3fv(U.uCam, eye);
}
function project(p) {
const m = VPM, x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15];
if (w <= 0.01) return null; return [(x / w * 0.5 + 0.5) * G.vw.w + G.vw.x, (1 - (y / w * 0.5 + 0.5)) * G.vw.h + G.vw.y];
}
function render() {
if (!gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2) * [0.55, 0.8, 1][SAVE.opt.q];
const cw = Math.max(1, innerWidth), ch = Math.max(1, innerHeight);
const W = Math.round(cw * dpr), H = Math.round(ch * dpr);
if (cvEl.width !== W || cvEl.height !== H) { cvEl.width = W; cvEl.height = H; }
let vw = { x: 0, y: 0, w: cw, h: ch };
if (G.mode === 'garage') { // keep the robot out from under the garage panel
const pn = !$('garage').classList.contains('hide') ? $('garage') : $('picker');
const r = pn.getBoundingClientRect();
if (r.width > 0) vw = innerWidth > innerHeight ? { x: 0, y: 0, w: Math.max(1, cw - r.width), h: ch } : { x: 0, y: 0, w: cw, h: Math.max(1, ch - r.height) };
}
G.vw = vw;
gl.viewport(0, 0, W, H); gl.clearColor(.06, .07, .1, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(Math.round(vw.x * dpr), Math.round((ch - vw.y - vw.h) * dpr), Math.round(vw.w * dpr), Math.round(vw.h * dpr));
camera(vw.w / vw.h);
drawArena();
for (const b of G.bots) if (!b.frozen) drawBot(b);
drawShadows(); drawFX(); drawGlass();
}
// ---------------- game state ----------------
const G = { mode: 'menu', bots: [], me: null, phase: 'none', clock: 0, t: 0, len: 0, cd: 0, pitOpen: false, pitLid: 1, touching: {}, ramT: {}, paused: false,
gYaw: 0.6, camF: [0, 0, -1], camR: [1, 0, 0], vw: { x: 0, y: 0, w: 1, h: 1 }, net: false, host: true, msgT: 0, fightStart: 0, kind: '' };
const inFight = () => G.mode === 'fight' || G.mode === 'test';
function msg(s, t) { $('msg').textContent = s; G.msgT = t || 2; if (G.host && G.net) NET.msgq = s; }
function ko(b, why) {
if (b.out) return;
b.out = true; b.why = why; b.input = { x: 0, y: 0, f: false };
if (G.mode === 'test') { G.respawn = 2.5; return; }
const txt = { pit: 'fell in the pit!', destroyed: 'is knocked out!', countout: 'could not move for 10 seconds!', left: 'left the game.' }[why] || 'is out!';
msg(b.name + ' ' + txt, 2.5); snd('ko', 0.8);
}
function startFight(list, opt) {
// list: [{d, name, ctrl, ai, id, house}]
hideAll(); G.mode = opt.test ? 'test' : 'fight'; G.kind = opt.kind || ''; G.net = !!opt.net; G.host = opt.host !== false; G.paused = false;
G.bots = []; G.touching = {}; G.ramT = {}; FX.length = 0; G.me = null; G.result = null;
list.forEach((o, i) => {
const b = buildBot(o.d, o); const sp = SPAWN[i % 4]; place(b, sp[0], sp[1], sp[2]);
G.bots.push(b); if (o.ctrl === 'me') G.me = b;
});
G.len = opt.len || 0; G.t = G.len; G.clock = 0; G.phase = opt.test ? 'fight' : 'count'; G.cd = opt.test ? 0 : 3.99; G.lastCd = 4;
G.pitOpen = !!opt.test; G.pitLid = opt.test ? 0 : 1; G.fightStart = opt.test ? 0 : 4;
G.opt = opt; G.camZ = 0; G.camFc = null; G.camEye = null; G.chaseF = null;
$('hud').classList.remove('hide'); $('joyHint').classList.remove('hide'); $('msg').textContent = '';
$('pPit').classList.toggle('hide', !opt.test);
setupFireBtn(); buildHudTop();
IN.fire = false; IN.latch = false;
}
function setupFireBtn() {
const k = G.me ? G.me.wep.k : 'wedge';
fireBtn.textContent = { wedge: 'FLIP BACK', lift: 'LIFT', hammer: 'SMASH', flip: 'FLIP', bar: 'SPIN', drum: 'SPIN' }[k];
fireBtn.classList.toggle('hide', !G.me || (k === 'wedge' && !G.me.s.sr));
}
function fixedStep() {
G.clock += FIX;
if (G.mode === 'garage') { for (const b of G.bots) { b.input = { x: 0, y: 0, f: G.tryT > 0 }; } G.tryT -= FIX; }
for (const b of G.bots) {
if (b.ctrl === 'me' && inFight()) b.input = G.paused ? { x: 0, y: 0, f: false } : humanInput(b);
else if (b.ctrl === 'ai') aiThink(b);
else if (b.ctrl === 'net') { const i = NET.inputs[b.id]; b.input = i && performance.now() - i.t < 1000 ? { x: i.x, y: i.y, f: i.f || i.latch } : { x: 0, y: 0, f: false }; if (i) i.latch = false; }
else if (b.ctrl === 'none' && G.mode !== 'garage') b.input = { x: 0, y: 0, f: false };
b.press = b.input.f && !b.prevF; b.prevF = b.input.f;
let x = clamp(b.input.x, -1, 1), y = clamp(b.input.y, -1, 1);
if (upv(b)[1] < 0) x = -x; // upside down: swap the steering so it still feels the same
let l = y + x, r = y - x; const mx = Math.max(1, Math.abs(l), Math.abs(r));
b.cmd = b.out || G.phase === 'count' ? [0, 0] : [l / mx, r / mx];
}
for (let i = 0; i < SUB; i++) physStep(FIX / SUB);
for (const b of G.bots) b.flash = Math.max(0, b.flash - FIX);
if (inFight()) rules();
if (G.net && G.host) netHostTick();
}
function rules() {
if (G.phase === 'count') {
G.cd -= FIX; const c = Math.ceil(G.cd);
if (c !== G.lastCd) { G.lastCd = c; if (c > 0) { msg(String(c), 1); snd('beep'); } else { msg('FIGHT!', 1.2); snd('go'); G.phase = 'fight'; } }
return;
}
if (G.phase === 'fight') {
const el = G.clock - G.fightStart;
if (G.len) { G.t = Math.max(0, G.len - el); }
if (!G.pitOpen && G.mode === 'fight' && el >= PIT_AT) { G.pitOpen = true; msg('THE PIT IS OPEN!', 2.5); snd('pit'); }
for (const b of G.bots) {
if (b.frozen) continue;
if (!b.out) {
if (b.p[1] < -0.2 && G.pitOpen && inPit(b.p)) ko(b, 'pit');
const bu = upv(b), stuck = (bu[1] < 0.35 && !(bu[1] < -0.5 && b.s.inv)) || (b.hp.drv[0] <= 0 && b.hp.drv[1] <= 0);
if (stuck && V.len(b.v) < 0.3 && b.ctrl !== 'none') b.immob += FIX; else b.immob = Math.max(0, b.immob - FIX * 3);
if (b.immob > 13) ko(b, 'countout');
if (b.hp.frame < b.hp.frameMax * 0.3 && (b.smokeT -= FIX) <= 0) { b.smokeT = 0.25; smoke(V.add(b.p, [0, 0.08, 0])); }
} else if (b.why === 'destroyed' && (b.smokeT -= FIX) <= 0) { b.smokeT = 0.12; smoke(V.add(b.p, [0, 0.08, 0])); }
if (b.p[1] < -0.8) b.frozen = true;
// count time spent close to a rival, for the judges
if (!b.out) for (const o of G.bots) if (o !== b && !o.out && V.dist(o.p, b.p) < 0.9) { b.near += FIX; break; }
}
if (G.mode === 'test') {
if (G.respawn > 0 && (G.respawn -= FIX) <= 0) for (const b of G.bots) if (b.out || b.frozen) { const nb = buildBot(b.d, b); Object.assign(b, nb); place(b, b === G.me ? -1.4 : 0.8, 0, b === G.me ? 0 : Math.PI); if (b.ctrl === 'me') G.me = b; }
for (const b of G.bots) if (!b.out && b.p[1] < -0.2 && inPit(b.p)) { b.out = true; b.why = 'pit'; G.respawn = 2.5; if (b.ctrl === 'none') { msg('Into the pit! Nice push!', 2); snd('coin'); } }
return;
}
const alive = G.bots.filter(b => !b.out);
if (alive.length <= 1 || (G.len && G.t <= 0)) { G.phase = 'over'; G.overT = 2.5; if (G.len && G.t <= 0 && alive.length > 1) { msg("TIME! The judges decide...", 2.5); snd('buzz'); } }
return;
}
if (G.phase === 'over') { G.overT -= FIX; if (G.overT <= 0) { G.phase = 'done'; finishFight(); } }
}
function judge() {
const alive = G.bots.filter(b => !b.out);
if (alive.length === 1) return { win: alive[0], how: alive.length === G.bots.length ? '' : 'ko' };
if (!alive.length) return { win: null, how: 'draw' };
const sc = b => b.dealt + b.near * 0.6 + healthFrac(b) * 20;
alive.sort((a, b) => sc(b) - sc(a));
if (sc(alive[0]) - sc(alive[1]) < 1) return { win: null, how: 'draw' };
return { win: alive[0], how: 'judges' };
}
function finishFight() {
const r = judge();
const res = { win: r.win ? r.win.id : null, winName: r.win ? r.win.name : '', how: r.how,
bots: G.bots.map(b => ({ id: b.id, name: b.name, dealt: Math.round(b.dealt), why: b.why, out: b.out })) };
if (G.net && G.host) netSend({ t: 'end', r: res });
showResult(res);
}
function showResult(res) {
G.result = res;
$('hud').classList.add('hide');
const P = prof(), me = G.me, iWon = me && res.win === me.id;
let coins = 0, title, why = '';
const loser = res.bots.find(x => x.out && x.id !== res.win);
if (res.how === 'draw') { title = "It's a draw!"; why = 'Both robots fought hard.'; }
else { title = (iWon ? '🏆 You win!' : '🏆 ' + res.winName + ' wins!'); why = res.how === 'judges' ? 'The judges picked the robot that did the most damage.' : loser ? loser.name + ' ' + ({ pit: 'fell in the pit.', destroyed: 'was knocked out.', countout: 'got stuck for 10 seconds.', left: 'left.' }[loser.why] || 'is out.') : ''; }
if (G.kind === 'house' && me) {
const h = G.opt.house;
if (iWon) { coins = h.reward * (P.beat[h.id] ? 1 : 2); if (!P.beat[h.id]) why += ' First win against ' + h.name + ': double coins!'; P.beat[h.id] = true; P.w++; }
else { coins = 10; P.l++; why += ' Keep trying! Upgrades in the garage help.'; }
} else if (G.kind === 'net' && me) {
if (res.how === 'draw') coins = 30; else if (iWon) { coins = 60; P.w++; } else { coins = 20; P.l++; }
}
P.coins += coins; saveNow();
$('rTitle').textContent = title; $('rWhy').textContent = why;
$('rCoins').textContent = coins ? '+' + coins + ' coins (you have ' + P.coins + ')' : '';
$('rStats').innerHTML = res.bots.map(x => esc(x.name) + ': ' + x.dealt + ' damage done').join('<br>');
$('rAgain').classList.toggle('hide', G.kind === 'net' && !G.host);
$('rAgain').textContent = G.kind === 'net' ? '🥊 Fight again' : '🥊 Fight again';
if (G.kind === 'net' && !G.host) $('rWhy').textContent += ' Waiting for the host to start the next fight.';
$('result').classList.remove('hide');
if (coins) snd('coin');
G.mode = 'result';
}
// ---------------- HUD ----------------
let hudT = 0;
function buildHudTop() {
$('hudTop').innerHTML = G.bots.filter(b => b.ctrl !== 'none' || G.mode === 'fight').map(b =>
'<div class="bc" id="bc' + b.id + '"><div class="n" style="color:' + b.col + '">' + esc(b.name) + '</div><div class="bar"><i></i></div><div class="s"></div></div>').join('');
$('tags').innerHTML = G.bots.map(b => '<div class="tag" id="tg' + b.id + '" style="color:' + b.col + '">' + esc(b.name) + '<span class="cnt"></span></div>').join('');
}
function hud(dt) {
if (!inFight()) return;
for (const b of G.bots) {
const tg = $('tg' + b.id); if (!tg) continue;
const sp = !b.frozen && project(V.add(b.p, [0, 0.3, 0]));
if (sp) { tg.style.left = sp[0] + 'px'; tg.style.top = sp[1] + 'px'; tg.style.display = ''; } else tg.style.display = 'none';
}
if (G.msgT > 0 && (G.msgT -= dt) <= 0) $('msg').textContent = '';
if ((hudT -= dt) > 0) return; hudT = 0.12;
const t = G.len ? G.t : 0; $('timer').textContent = G.len ? Math.floor(t / 60) + ':' + String(Math.floor(t % 60)).padStart(2, '0') : 'Test';
if (G.mode === 'fight' && !G.pitOpen && G.phase === 'fight') $('timer').textContent += ' pit in ' + Math.ceil(PIT_AT - (G.clock - G.fightStart));
for (const b of G.bots) {
const el = $('bc' + b.id), tg = $('tg' + b.id);
const hf = healthFrac(b), stuck = b.immob > 3 && !b.out ? Math.max(0, Math.ceil(13 - b.immob)) : 0;
if (tg) tg.lastChild.textContent = stuck ? String(stuck) : '';
if (!el) continue;
const i = el.querySelector('i'); i.style.width = (hf * 100).toFixed(0) + '%'; i.style.background = hf > 0.5 ? '#21a35a' : hf > 0.25 ? '#f0c020' : '#d23a32';
const bu = upv(b);
el.querySelector('.s').textContent = b.out ? 'OUT' : stuck ? 'Stuck! ' + stuck : bu[1] < 0.35 ? 'Upside down' : b.hp.bat <= 0 ? 'Battery empty' : '';
}
const b = G.me;
if (b) {
const W = b.wep, bat = Math.round(b.hp.bat / b.hp.batMax * 100);
let w = '';
if (W.k === 'bar' || W.k === 'drum') w = (W.on ? 'Spinner ' : 'Spinner off ') + Math.round(W.om * 30 / Math.PI) + ' rpm';
else if (W.k === 'flip') w = 'Flips left: ' + W.shots;
else if (W.k === 'lift') w = 'Lifter'; else if (W.k === 'hammer') w = 'Hammer';
const drv = Math.min(b.hp.drv[0], b.hp.drv[1]);
$('mine').textContent = '🔋 ' + bat + '% ' + w + (b.hp.wep <= 0 && W.k !== 'wedge' ? ' (broken!)' : '') + (drv < 1 ? ' Motors ' + Math.round(drv * 100) + '%' : '');
} else $('mine').textContent = 'Watching';
}
// ---------------- menus ----------------
const SCREENS = ['title', 'garage', 'picker', 'practice', 'lobby', 'result', 'pause', 'settings'];
function hideAll() { for (const s of SCREENS) $(s).classList.add('hide'); }
function showTitle() {
hideAll(); $('hud').classList.add('hide'); G.mode = 'menu'; G.bots = []; G.me = null; G.net = false;
const P = prof();
$('tWho').innerHTML = (BC ? 'Hi ' + esc(BC.name) + '! ' : '') + 'Your robot: <b>' + esc(P.bot) + '</b> · ' + P.coins + ' coins';
$('title').classList.remove('hide');
// a robot to look at behind the menu
const b = buildBot(P.design, { name: P.bot, ctrl: 'none' }); place(b, 0, 0.3, -0.5); G.bots = [b];
}
$('bGarage').onclick = () => { ac(); openGarage(); };
$('bPractice').onclick = () => { ac(); showPractice(); };
$('bTest').onclick = () => { ac(); testDrive(); };
$('bFriends').onclick = () => { ac(); openLobby(); };
$('bSettings').onclick = () => { hideAll(); updSettings(); $('settings').classList.remove('hide'); };
function testDrive(fromGarage) {
const P = prof(); if (!fightOk(P)) return;
startFight([{ d: P.design, name: P.bot, ctrl: 'me' }, { d: HOUSE[0].d, name: 'Crash dummy', ctrl: 'none', id: 1 }], { test: true });
G.fromGarage = !!fromGarage;
place(G.bots[1], 0.8, 0, Math.PI);
msg('Test drive! Push the dummy into the pit.', 3);
}
function fightOk(P) {
const s = stats(P.design);
if (s.lb > LIMIT_LB) { alert('Your robot weighs ' + s.lb.toFixed(1) + ' lb. The limit is ' + LIMIT_LB + ' lb. Take something off in the garage first.'); return false; }
return true;
}
function showPractice() {
hideAll(); G.mode = 'menu'; const P = prof();
$('prList').innerHTML = '';
HOUSE.forEach(h => {
const d = document.createElement('div'); d.className = 'card';
const stars = '⭐'.repeat(h.lvl + 1);
d.innerHTML = '<div><div class="who" style="color:' + h.col + '">' + esc(h.name) + ' ' + stars + (P.beat[h.id] ? ' ✅' : '') + '</div><div class="inf">' + esc(h.info) + '</div><div class="inf">Win: ' + h.reward + ' coins' + (P.beat[h.id] ? '' : ' (x2 the first time)') + '</div></div>';
const bt = document.createElement('button'); bt.className = 'go'; bt.textContent = 'Fight'; bt.setAttribute('aria-label', 'Fight ' + h.name);
bt.onclick = () => practiceFight(h); d.appendChild(bt); $('prList').appendChild(d);
});
$('practice').classList.remove('hide');
}
function practiceFight(h) {
const P = prof(); if (!fightOk(P)) return;
startFight([{ d: P.design, name: P.bot, ctrl: 'me' }, { d: Object.assign({ col: h.col }, h.d), name: h.name, ctrl: 'ai', ai: { lvl: h.lvl }, id: 1, house: h }], { kind: 'house', house: h, len: 120 });
}
$('prBack').onclick = showTitle;
$('rAgain').onclick = () => {
if (G.kind === 'house') practiceFight(G.opt.house);
else if (G.kind === 'net') { hideAll(); $('lobby').classList.remove('hide'); hostStart(); }
};
$('rGarage').onclick = () => { if (G.kind === 'net') netLeave(); openGarage(); };
$('rMenu').onclick = () => { if (G.kind === 'net') netLeave(); showTitle(); };
function pauseGame() { if (G.mode !== 'fight' && G.mode !== 'test') return; G.paused = !G.net; $('pause').classList.remove('hide'); $('pResume').textContent = G.net ? '▶ Back to the fight' : '▶ Keep going'; }
$('bPause').onclick = pauseGame;
$('pResume').onclick = () => { G.paused = false; $('pause').classList.add('hide'); };
$('pPit').onclick = () => { G.pitOpen = !G.pitOpen; G.pitLid = G.pitOpen ? 0 : 1; G.paused = false; $('pause').classList.add('hide'); };
$('pQuit').onclick = () => { G.paused = false; const fg = G.fromGarage && G.mode === 'test'; if (G.net) netLeave(); if (fg) openGarage(); else showTitle(); };
function toggleCam() { SAVE.opt.cam = SAVE.opt.cam === 'arena' ? 'chase' : 'arena'; saveNow(); G.camEye = null; msg(SAVE.opt.cam === 'arena' ? 'Arena camera' : 'Chase camera', 1); }
$('bCam').onclick = toggleCam;
function updSettings() {
$('sSteer').textContent = 'Steering: ' + (SAVE.opt.easy ? 'Easy' : 'Classic');
$('sSteerInf').textContent = SAVE.opt.easy ? 'Easy: the robot drives where you point on the screen.' : 'Classic: up drives forward, left and right turn, like a real radio controller.';
$('sCam').textContent = 'Camera: ' + (SAVE.opt.cam === 'arena' ? 'Arena (whole box)' : 'Chase (behind my robot)');
$('sQual').textContent = 'Picture: ' + ['Fast (for small tablets)', 'Normal', 'Sharp'][SAVE.opt.q];
$('sSnd').textContent = 'Sound: ' + (SAVE.opt.snd ? 'On' : 'Off');
}
$('sSteer').onclick = () => { SAVE.opt.easy = !SAVE.opt.easy; saveNow(); updSettings(); };
$('sCam').onclick = () => { SAVE.opt.cam = SAVE.opt.cam === 'arena' ? 'chase' : 'arena'; saveNow(); updSettings(); };
$('sQual').onclick = () => { SAVE.opt.q = (SAVE.opt.q + 1) % 3; saveNow(); updSettings(); };
$('sSnd').onclick = () => { SAVE.opt.snd = !SAVE.opt.snd; saveNow(); updSettings(); };
$('sBack').onclick = showTitle;
// ---------------- garage ----------------
function openGarage() {
hideAll(); $('hud').classList.add('hide'); G.mode = 'garage'; G.net = false; G.host = true; G.phase = 'none'; G.pitOpen = false; G.pitLid = 1;
rebuildPreview(); updGarage(); $('garage').classList.remove('hide');
}
function rebuildPreview() {
const P = prof(); const b = buildBot(P.design, { name: P.bot, ctrl: 'none' }); place(b, 0, 0, 0); G.bots = [b]; G.me = b; G.tryT = 0;
}
const tenBar = (v, warn) => '<div class="bar"><i style="width:' + clamp(v * 100, 0, 100).toFixed(0) + '%;background:' + (warn ? '#d23a32' : '#21a35a') + '"></i></div>';
function statsHtml(s) {
const over = s.lb > LIMIT_LB;
const wpow = { wedge: .15, lift: .3, hammer: .5, flip: .55 }[s.we.kind] || s.kj / 2.8;
const armor = (s.ar.hp * 5 + s.f.hp) / (105 * 5 + 170);
const ud = s.inv ? '<span class="good">Keeps driving</span>' : s.sr ? '<span class="good">Flips itself back</span>' : '<span class="warn">Gets stuck! Add a self-righting bar or big wheels.</span>';
const volt = s.vs < 0.95 ? '<span class="warn">Motors want more volts: only ' + Math.round(s.vs * 100) + '% speed</span>' : s.vs > 1.05 ? 'Extra volts: ' + Math.round(s.vs * 100) + '% speed' : 'Volts match';
return '<span>Weight</span><div>' + tenBar(s.lb / LIMIT_LB, over) + '<span class="' + (over ? 'warn' : '') + '">' + s.lb.toFixed(1) + ' of ' + LIMIT_LB + ' lb' + (over ? ' - TOO HEAVY to fight!' : '') + '</span></div>' +
'<span>Top speed</span><div>' + tenBar(s.top * MPH / 14) + (s.top * MPH).toFixed(1) + ' mph</div>' +
'<span>Pushing power</span><div>' + tenBar(s.push / 300) + Math.round(s.push) + ' newtons</div>' +
'<span>Armor</span><div>' + tenBar(armor) + '</div>' +
'<span>Weapon</span><div>' + tenBar(wpow) + (s.kj ? s.kj.toFixed(1) + ' kJ of spin energy' : s.we.name) + '</div>' +
'<span>Battery</span><div>About ' + s.mins.toFixed(1) + ' min of hard driving' + (s.mins < 3 ? ' <span class="warn">(a fight is 2 to 3 min)</span>' : '') + '<br>' + volt + '</div>' +
'<span>Upside down</span><div>' + ud + '</div>';
}
function updGarage() {
const P = prof(), s = stats(P.design);
$('gCoins').textContent = P.coins + ' coins';
$('gName').value = P.bot;
$('gPaint').innerHTML = '';
for (const c of PAINTS) { const b = document.createElement('button'); b.style.background = c; b.setAttribute('aria-label', 'Paint colour ' + c); b.setAttribute('aria-pressed', P.design.col === c);
b.onclick = () => { P.design.col = c; saveNow(); rebuildPreview(); updGarage(); }; $('gPaint').appendChild(b); }
$('gSlots').innerHTML = '';
for (const [slot, label] of SLOTS) {
const p = part(slot, P.design[slot]), b = document.createElement('button'); b.className = 'slot';
b.innerHTML = '<span><small>' + label + '</small>' + esc(p.name) + '</span><b>›</b>';
b.onclick = () => openPicker(slot, label); $('gSlots').appendChild(b);
}
$('gStats').innerHTML = statsHtml(s);
$('gTry').classList.toggle('hide', s.we.kind === 'wedge');
}
$('gName').onchange = () => { const P = prof(); P.bot = $('gName').value.trim().slice(0, 16) || P.bot; saveNow(); G.bots[0].name = P.bot; };
$('gBack').onclick = () => { $('gName').onchange(); showTitle(); };
$('gDrive').onclick = () => { $('gName').onchange(); testDrive(true); };
$('gTry').onclick = () => { ac(); const b = G.bots[0]; if (!b) return; G.tryT = b.wep.k === 'lift' ? 1.2 : 0.1; if (b.wep.k === 'bar' || b.wep.k === 'drum') G.tryT = 0.05; };
function slotLine(slot, p, s) {
switch (slot) {
case 'frame': return 'Strength ' + p.hp + ' · ' + Math.round(p.L * 39.4) + ' x ' + Math.round(p.W * 39.4) + ' inches';
case 'drive': return p.n + '-wheel drive · top speed ' + (s.top * MPH).toFixed(1) + ' mph · push ' + Math.round(s.push) + ' N';
case 'wheels': return 'Grip ' + Math.round(p.mu * 100) + '% · top speed ' + (s.top * MPH).toFixed(1) + ' mph' + (s.inv ? ' · drives upside down' : '');
case 'battery': return p.wh + ' watt-hours · about ' + s.mins.toFixed(1) + ' min hard driving' + (s.vs < 0.95 ? ' · too few volts for your motors' : '');
case 'armor': return 'Protection ' + p.hp + ' per side' + (p.spin < 1 ? ' · extra good against spinners' : '');
case 'weapon': return s.kj ? s.kj.toFixed(1) + ' kJ of spin energy' : '';
default: return '';
}
}
function openPicker(slot, label) {
const P = prof();
$('garage').classList.add('hide'); $('picker').classList.remove('hide');
$('pkTitle').textContent = label; $('pkCoins').textContent = P.coins + ' coins';
const box = $('pkList'); box.innerHTML = '';
for (const p of PARTS[slot]) {
const d2 = Object.assign({}, P.design); d2[slot] = p.id; const s = stats(d2);
const cur = P.design[slot] === p.id, have = owns(P, slot, p.id);
const el = document.createElement('div'); el.className = 'opt' + (cur ? ' cur' : '');
const each = slot === 'wheels' ? ' each' : '';
el.innerHTML = '<div class="nm">' + esc(p.name) + (cur ? ' ✔' : '') + '</div><div class="inf">' + esc(p.info || '') + '</div>' +
'<div class="st">' + [p.lb ? p.lb + ' lb' + each : '', slotLine(slot, p, s)].filter(Boolean).map(esc).join(' · ') + '</div>' +
'<div class="st' + (s.lb > LIMIT_LB ? ' warn' : '') + '">Robot weight with this: ' + s.lb.toFixed(1) + ' lb' + (s.lb > LIMIT_LB ? ' (too heavy!)' : '') + '</div>';
const bt = document.createElement('button');
if (cur) { bt.textContent = 'On your robot'; bt.disabled = true; }
else if (have) { bt.textContent = 'Use this'; bt.className = 'go'; bt.onclick = () => { P.design[slot] = p.id; saveNow(); rebuildPreview(); openPicker(slot, label); }; }
else { bt.textContent = 'Buy for ' + p.cost + ' coins'; bt.className = 'buy'; bt.disabled = P.coins < p.cost;
if (P.coins < p.cost) bt.textContent += ' (need ' + (p.cost - P.coins) + ' more)';
bt.onclick = () => { if (P.coins < p.cost) return; P.coins -= p.cost; P.owned[slot + ':' + p.id] = true; P.design[slot] = p.id; saveNow(); snd('coin'); rebuildPreview(); openPicker(slot, label); }; }
el.appendChild(bt); box.appendChild(el);
}
}
$('pkBack').onclick = () => { $('picker').classList.add('hide'); updGarage(); $('garage').classList.remove('hide'); };
// ---------------- networking (games relay on CT 127, /mp/ws) ----------------
const NET = { ws: null, id: 0, host: 0, room: '', players: [], inRoom: false, inputs: {}, collect: null, fxq: [], msgq: '', sendT: 0, goT: null };
function netConnect() {
if (NET.ws && NET.ws.readyState <= 1) return;
$('lbStatus').textContent = 'Connecting...';
let ws; try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/mp/ws'); } catch (e) { $('lbStatus').textContent = 'Could not connect.'; return; }
NET.ws = ws;
ws.onopen = () => { netSend(helloMsg()); netSend({ t: 'list' }); $('lbStatus').textContent = 'Connected. Start a fight room, or join one.'; };
ws.onmessage = ev => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } onNet(m); };
ws.onclose = () => {
const was = NET.inRoom; NET.ws = null; NET.inRoom = false; NET.players = [];
if (was && G.net && inFight()) { msg('LOST CONNECTION', 3); setTimeout(showTitle, 2500); }
$('lbStatus').textContent = 'Not connected. Is the internet working?'; showLobby();
};
}
function netSend(m) { if (NET.ws && NET.ws.readyState === 1) NET.ws.send(JSON.stringify(m)); }
function netLeave() { netSend({ t: 'leave' }); NET.inRoom = false; NET.room = ''; NET.players = []; G.net = false; }
function helloMsg() { return { t: 'hello', game: 'bots', who: BC ? BC.who : '', key: BC ? BC.key : '', name: myName(), ch: 0, col: prof().design.col }; }
function myBotMsg() { const P = prof(); return { name: P.bot, d: P.design, owner: myName() }; }
const amHost = () => NET.inRoom && NET.host === NET.id;
function openLobby() {
hideAll(); G.mode = 'lobby'; $('lobby').classList.remove('hide');
$('lbName').value = myName(); $('lbName').disabled = !!BC; $('lbSigned').classList.toggle('hide', !BC);
netConnect(); showLobby();
}
$('lbName').onchange = () => { if (BC) return; SAVE.opt.name = $('lbName').value.trim().slice(0, 12); saveNow(); netSend(helloMsg()); };
$('bCreate').onclick = () => { if (!fightOk(prof())) return; $('lbName').onchange(); netSend({ t: 'create' }); };
$('bLbBack').onclick = () => { if (NET.inRoom) netLeave(); showTitle(); };
$('bStartMP').onclick = () => hostStart();
let roomTimer = null;
function showLobby() {
if ($('lobby').classList.contains('hide')) { clearInterval(roomTimer); roomTimer = null; return; }
$('lbOut').classList.toggle('hide', NET.inRoom); $('lbIn').classList.toggle('hide', !NET.inRoom);
if (!roomTimer) roomTimer = setInterval(() => { if (!$('lobby').classList.contains('hide') && !NET.inRoom) netSend({ t: 'list' }); }, 2000);
if (NET.inRoom) {
const host = NET.players.find(p => p.id === NET.host);
$('lbRoom').innerHTML = '<h2>Fight room ' + esc(NET.room) + '</h2>' + NET.players.map(p => '<div class="card"><span class="who" style="color:' + (p.col || '#fff') + '">' + esc(p.name) + (p.id === NET.host ? ' (host)' : '') + (p.id === NET.id ? ' - you' : '') + '</span></div>').join('');
$('bStartMP').classList.toggle('hide', !amHost());
$('bStartMP').disabled = NET.players.length < 2;
$('lbStatus').textContent = amHost() ? (NET.players.length < 2 ? 'Waiting for friends to join room ' + NET.room + '...' : 'Everyone ready? Press Start!') : 'Waiting for ' + (host ? host.name : 'the host') + ' to start the fight.';
}
}
function showRooms(rooms) {
const box = $('rooms'); box.innerHTML = rooms.length ? '' : '<div class="sub">No fight rooms yet. Start one, then your friends can join it.</div>';
for (const r of rooms) {
const d = document.createElement('div'); d.className = 'card';
d.innerHTML = '<span class="who">' + esc(r.host) + "'s room " + esc(r.code) + ' (' + r.n + '/4)' + (r.started ? ' - fighting' : '') + '</span>';
const b = document.createElement('button'); b.className = 'go'; b.textContent = 'Join';
b.onclick = () => { if (!fightOk(prof())) return; $('lbName').onchange(); netSend({ t: 'join', code: r.code }); };
d.appendChild(b); box.appendChild(d);
}
}
function hostStart() {
if (!amHost() || NET.players.length < 2) { showLobby(); return; }
NET.collect = new Map(); NET.collect.set(NET.id, myBotMsg());
netSend({ t: 'started', v: true }); netSend({ t: 'start' });
$('lbStatus').textContent = 'Getting the robots ready...';
clearTimeout(NET.goT); NET.goT = setTimeout(hostGo, 3000);
}
function hostGo() {
clearTimeout(NET.goT); if (!NET.collect || !amHost()) return;
const bots = []; for (const p of NET.players) { const c = NET.collect.get(p.id); if (c && bots.length < 4) bots.push(Object.assign({ id: p.id }, c)); }
NET.collect = null;
if (bots.length < 2) { $('lbStatus').textContent = 'Your friends did not answer. Try again.'; return; }
const m = { t: 'go', bots, len: 180 }; netSend(m); netStart(m);
}
function netStart(m) {
const list = m.bots.map(o => ({ d: o.d, name: o.name, id: o.id, ctrl: o.id === NET.id ? 'me' : amHost() ? 'net' : 'remote' }));
NET.inputs = {};
startFight(list, { kind: 'net', net: true, host: amHost(), len: m.len });
}
function onNet(m) {
switch (m.t) {
case 'id': NET.id = m.id; return;
case 'rooms': showRooms(m.rooms); return;
case 'err': $('lbStatus').textContent = m.msg; return;
case 'room': {
const oldHost = NET.host; NET.room = m.code; NET.host = m.host; NET.players = m.players; NET.inRoom = true;
if (G.net && inFight()) {
for (const b of G.bots) if (!m.players.find(p => p.id === b.id) && !b.out) { if (G.host) ko(b, 'left'); }
if (oldHost !== m.host && !G.host) { msg('The host left. Fight over.', 3); G.net = false; setTimeout(() => { hideAll(); $('hud').classList.add('hide'); G.mode = 'lobby'; G.bots = []; $('lobby').classList.remove('hide'); showLobby(); }, 2500); }
}
if (G.mode === 'lobby') showLobby();
return; }
}
if (!NET.inRoom) return;
const from = m.f;
switch (m.t) {
case 'start': if (from !== NET.host) return; netSend(Object.assign({ t: 'bot', to: NET.host }, myBotMsg()));
if (G.mode === 'result') { hideAll(); G.mode = 'lobby'; $('lobby').classList.remove('hide'); showLobby(); }
$('lbStatus').textContent = 'Getting the robots ready...'; return;
case 'bot': if (amHost() && NET.collect) { NET.collect.set(from, { name: String(m.name || 'Robot').slice(0, 16), d: m.d, owner: m.owner }); if (NET.collect.size >= NET.players.length) hostGo(); } return;
case 'go': if (from === NET.host) netStart(m); return;
case 'in': if (amHost()) { const o = NET.inputs[from] || (NET.inputs[from] = {}); o.x = +m.x || 0; o.y = +m.y || 0; o.f = !!m.w; if (m.w) o.latch = true; o.t = performance.now(); } return;
case 's': if (from === NET.host && G.net && !G.host) applySnap(m); return;
case 'end': if (from === NET.host && G.net && !G.host && inFight()) showResult(m.r); return;
}
}
const WHY = ['', 'pit', 'destroyed', 'countout', 'left'];
const r3 = x => Math.round(x * 1000) / 1000;
function netHostTick() {
if (++NET.sendT < 3) return; NET.sendT = 0;
const bs = G.bots.map(b => [b.id, r3(b.p[0]), r3(b.p[1]), r3(b.p[2]), r3(b.q[0]), r3(b.q[1]), r3(b.q[2]), r3(b.q[3]), r3(b.v[0]), r3(b.v[1]), r3(b.v[2]),
r3(b.wep.a), Math.round(b.wep.om), b.out ? 1 + WHY.indexOf(b.why) * 2 : 0, b.hp.arm.map(x => Math.round(Math.max(0, x))), Math.round(b.hp.frame),
r3(b.hp.drv[0]), r3(b.hp.drv[1]), r3(b.hp.wep), r3(b.hp.bat), b.wep.shots, Math.round(b.immob * 10) / 10, r3(b.cmd[0]), r3(b.cmd[1]), b.frozen ? 1 : 0, Math.round(b.dealt), b.wep.on ? 1 : 0]);
const m = { t: 's', b: bs, ph: G.phase, tm: r3(G.t), cl: r3(G.clock), pit: G.pitOpen ? 1 : 0, e: NET.fxq };
if (NET.msgq) { m.m = NET.msgq; NET.msgq = ''; }
NET.fxq = []; netSend(m);
}
function applySnap(m) {
G.phase = m.ph; G.t = m.tm; if (!G.pitOpen && m.pit) { snd('pit'); } G.pitOpen = !!m.pit; G.clock = m.cl;
for (const a of m.b) {
const b = G.bots.find(x => x.id === a[0]); if (!b) continue;
b.np = [a[1], a[2], a[3]]; b.nq = [a[4], a[5], a[6], a[7]]; b.v = [a[8], a[9], a[10]]; b.nt = performance.now();
if (!b.seen) { b.p = b.np.slice(); b.q = b.nq.slice(); b.seen = true; }
b.wep.na = a[11]; b.wep.om = a[12];
const fl = a[13]; if (fl && !b.out) { b.out = true; b.why = WHY[(fl - 1) >> 1] || ''; } else if (!fl) b.out = false;
const lost = b.hp.frame - a[15]; if (lost > 0.5) b.flash = 0.15;
b.hp.arm = a[14]; b.hp.frame = a[15]; b.hp.drv = [a[16], a[17]]; b.hp.wep = a[18]; b.hp.bat = a[19]; b.wep.shots = a[20]; b.immob = a[21];
b.cmd = [a[22], a[23]]; b.frozen = !!a[24]; b.dealt = a[25]; b.wep.on = !!a[26];
}
for (const e of m.e || []) { G.host = true; const n = G.net; G.net = false; sparks([e[0], e[1], e[2]], e[3]); G.net = n; G.host = false; snd(e[3] > 9 ? 'hit' : 'clank', Math.min(1, e[3] / 20 + 0.3)); }
if (m.m) msg(m.m, 2.5);
if (m.ph === 'count') { const c = Math.ceil(m.tm >= 0 ? (G.lastSnapCd = Math.ceil(4 - m.cl)) : 0); if (c !== G.lastCd && c > 0 && c < 4) { G.lastCd = c; msg(String(c), 1); snd('beep'); } }
if (m.ph === 'fight' && G.lastCd !== 0) { G.lastCd = 0; msg('FIGHT!', 1.2); snd('go'); }
}
function guestStep(dt) {
// send my stick to the host and smoothly follow the host's snapshots
if (G.me) {
const i = G.paused ? { x: 0, y: 0, f: false } : humanInput(G.me);
G.inLatch = G.inLatch || i.f;
// the weapon goes in 'w', not 'f': the relay stamps the sender's id into 'f'
if ((NET.sendT += dt) >= 0.05) { NET.sendT = 0; netSend({ t: 'in', to: NET.host, x: r3(i.x), y: r3(i.y), w: G.inLatch || i.f ? 1 : 0 }); G.inLatch = false; }
}
const now = performance.now(), k = 1 - Math.exp(-dt * 16);
for (const b of G.bots) {
if (!b.np) continue;
const ahead = Math.min(0.12, (now - b.nt) / 1000), tp = V.madd(b.np, b.v, ahead);
b.p = V.add(V.mul(b.p, 1 - k), V.mul(tp, k)); b.q = qnlerp(b.q, b.nq, k);
const bf = fwd(b), vl = b.v[0] * bf[0] + b.v[2] * bf[2];
for (const w of b.wheels) w.ang -= (Math.abs(vl) > 0.05 ? vl / w.r : b.cmd[w.side < 0 ? 0 : 1] * 15) * dt;
if (b.wep.na != null) b.wep.a = lerp(b.wep.a, b.wep.na, 0.5);
if (b.wep.k === 'bar' || b.wep.k === 'drum') b.wep.ang = (b.wep.ang + b.wep.om * dt) % (Math.PI * 2);
updArms(b); b.flash = Math.max(0, b.flash - dt);
if (b.out && b.why === 'destroyed' && Math.random() < dt * 8) smoke(V.add(b.p, [0, 0.08, 0]));
}
if (G.phase === 'fight' && G.pitLid > 0 && G.pitOpen) G.pitLid = 0;
}
// ---------------- main loop ----------------
let last = performance.now(), acc = 0;
function tick(dt) {
if (G.mode === 'fight' || G.mode === 'test' || G.mode === 'garage' || G.mode === 'menu' || G.mode === 'lobby' || G.mode === 'result') {
if (G.net && !G.host && inFight()) guestStep(dt);
else if (!G.paused && G.mode !== 'result' && (G.mode !== 'menu' && G.mode !== 'lobby' || G.bots.length)) {
acc = Math.min(acc + dt, 0.1);
while (acc >= FIX) { fixedStep(); acc -= FIX; }
}
}
if (G.mode === 'menu' || G.mode === 'lobby') G.gYaw += dt * 0.25;
if (G.mode === 'garage' && !G.drag) G.gYaw += dt * 0.2;
if (G.pitOpen && G.pitLid > 0) G.pitLid = Math.max(0, G.pitLid - dt * 1.5);
stepFX(dt); hud(dt); soundLoop();
}
function frame(t) {
requestAnimationFrame(frame);
const dt = Math.min(0.1, Math.max(0, (t - last) / 1000)); last = t;
tick(dt); render();
}
if (gl) { initGL(); MB = boxGeo(); MC = cylGeo(18); }
showTitle();
requestAnimationFrame(frame);
// test hook (used from the browser console / automated checks)
window.BOTSdebug = { G, NET, PARTS, HOUSE, stats, buildBot, prof, fixedStep, startFight, practiceFight, testDrive, showTitle, openGarage, IN,
step(n) { for (let i = 0; i < (n || 1); i++) fixedStep(); render(); hud(0.2); } };
</script>
</body>
</html>
EOF
3. Copy the icons
make-icons.ps1 (from the games site post) draws this game's icons into a bots subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes bots\icon-512.png bots\icon-192.png bots\apple-touch-icon.png bots\favicon-32.png proxmox:/root/nibbles/bots/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a bots folder:
bash /root/nibbles/05-push-site.sh
5. Check it
Proxmox host shell. Should print 200:
pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/bots/
Then open https://games.503mail.com/bots/ in a browser.
6. Multiplayer
Fight friends uses the same games relay as Super Pip (the games-relay service in CT 127 and the /mp/* route on CT 108), as game bots. Nothing on the relay changes. If it isn't set up yet, follow step 6 of the Super Pip post. Check it from anywhere; it should print ok rooms=0 players=0:
curl -s https://games.503mail.com/mp/health
The room's first player (the host) runs the physics for every robot. The other tablets send only their stick and weapon button 20 times a second, and draw the robots from the host's updates (also 20 times a second), so all the tablets agree on every hit. If the host leaves, the fight ends for everyone. If another player leaves, their robot is out.
Updating it later
Edit index.html, paste the new version over /root/nibbles/bots/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.
Troubleshooting
The game runs entirely in the browser, so the server's only job is handing out the page.
- The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print
200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy). - The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for
/favicon.icois harmless (browsers ask for it on their own, and the games use PNG icons instead). - Settings or best scores look wrong: they're kept in the browser's localStorage under 'bots_save' (coins, bought parts and the robot design, per BlockCraft name). Clearing the site's data in the browser resets them; nothing is stored on the server.
- Fight friends says "Not connected": see the relay troubleshooting in the Super Pip post.
- A fight with friends freezes for everyone: the host's tablet went to sleep or switched apps, and the host runs the fight. Wake it and the fight carries on.
- "Too heavy to fight": the robot is over 30 lb. The garage shows the weight in red; swap a part for a lighter one.
Notes
- How the physics works: each robot is one rigid body (position, velocity, rotation, spin) with weight and inertia from its parts, stepped 300 times a second. Each wheel is a short spring to the floor. Its push comes from a DC motor curve: full force when stalled, none at top speed, and never more than the tyre's grip times the weight on it. The frame, wedge, arms and drum are boxes that push other robots out with springy contact forces, which is what lets a wedge slide under a robot and lift it. Spinners store real energy (½ I ω²). A full-speed bar holds about 2.5 kJ (a real featherweight bar is in that range), and a hit hands part of it to the other robot as damage and a shove, and kicks the spinner back the other way.
- Bugs the tests found: a flipper throw was a fixed push, so a light 14 lb robot flew 5 m into the air. It is now a set launch speed of about 5 m/s (a 1 to 2 m throw). Four-wheel-drive robots only turned 42° a second, because the tyre model let sideways scrub swamp the motors. Now a slipping tyre's grip follows its slip direction, as on a real wheel spinning faster than the ground, and four-wheel drive turns about 300° a second. Self-righting never fired, because it checked "touching the floor" before the floor contacts were worked out that step. A robot lying on its side flipped too far, onto its back; the righting spin now depends on how far it has to turn. In multiplayer, the guests' weapon button was always on: the relay writes the sender's id into the
ffield of every message, which is where the button was being sent. - Checked by script: practice fights against every house robot ran at high speed with a debug hook (
BOTSdebugin the browser console). The checks: top speeds match the garage's numbers (4.8 mph starter, 11.8 mph brushless on 8 inch wheels); every self-righting weapon flips the robot back in one press from its back, side or nose; a plain wedge stays stuck and is counted out; the pit, destroyed and count-out knockouts end the fight. Multiplayer was tested on the live site with two browser tabs: joining, driving, the weapon button, the result screen, rematch, and a player leaving. - Name: BattleBots and Robot Wars are trademarks, so the game, the house robots and the arena have their own names. Robot combat itself (wedges, spinners, flippers, the pit) isn't owned by anyone.