<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ant Games Dev Blog]]></title><description><![CDATA[Ant Games Dev Blog]]></description><link>https://antgamesai.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 18:33:35 GMT</lastBuildDate><atom:link href="https://antgamesai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[A xiangqi (Chinese chess) engine in one HTML file: move generation, perpetual check and an AI in a Web Worker]]></title><description><![CDATA[How a browser xiangqi engine generates moves, rules perpetual check and stalemate, and plays from a Web Worker. Our own project.]]></description><link>https://antgamesai.hashnode.dev/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check-and-an-ai-in-a-web-worker</link><guid isPermaLink="true">https://antgamesai.hashnode.dev/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check-and-an-ai-in-a-web-worker</guid><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Ant Games]]></dc:creator><pubDate>Sun, 15 Nov 2026 00:16:38 GMT</pubDate><content:encoded><![CDATA[<p>CHINESE CHESS is a xiangqi game you play against the computer in a browser tab. It is one of the free games on <a href="https://antgames.ai/">Ant Games (antgames.ai)</a>, our own site: no ads, no purchases, no sign-up. It was built by AI agents (Claude Code) directed by a human owner, and you can <a href="https://antgames.ai/play/chinesechess/">play it here</a>. The interface is in English.</p>
<p>The whole game is one HTML file of 155,830 bytes (53,801 gzipped). It holds the engine, three levels, synthesised music and 135 Tang poems, and makes no network request. This post is about the engine: the board, move generation, the rules that trip people up, and how the opponent thinks without freezing the page. The snippets are copied from the game's TypeScript source; the rules engine and search are about 1,050 non-blank lines.</p>
<h2>Just enough rules</h2>
<p>Xiangqi is played on the points of a 9 by 10 grid, with a river across the middle and a 3 by 3 palace on each side. Each side has a general and two advisors (both stay in the palace), two elephants (two points diagonally, never across the river), two horses, two chariots, two cannons and five soldiers. Four rules matter for the code:</p>
<ul>
<li>A horse moves one point straight, then one diagonally outward, and is blocked if the first point is occupied. An elephant is blocked by a piece on the point between ("the eye").</li>
<li>A cannon moves like a chariot but captures only by jumping exactly one piece, the screen.</li>
<li>The two generals may never face each other on an open file.</li>
<li>A side with no legal move has lost, whether or not it is in check. Stalemate is a loss, not a draw.</li>
</ul>
<h2>The board: 90 numbers and some precomputed tables</h2>
<p>A position is an <code>Int8Array(90)</code>, point <code>rank * 9 + file</code>, rank 0 being Black's back rank. A piece is one byte, <code>type | (colour + 1) &lt;&lt; 3</code>, so Red pieces are 9 to 15 and Black pieces 17 to 23, and "is this mine?" is one AND against <code>8 &lt;&lt; side</code>. A move is <code>from | to &lt;&lt; 7</code> in one integer.</p>
<p>All the geometry is worked out once at load time in <code>tables.ts</code>: for every point, where a general, advisor or soldier can step, and four rays for the sliding pieces. The horse and elephant tables store pairs, the target and the point that must be empty (<code>[to, leg, to, leg, ...]</code>), and the elephant table has the river built in. Move generation then does no geometry, and the blocked horse or elephant is one comparison, <code>if (b[st[i + 1]!] !== 0) continue;</code>, commented "the eye, or the leg, is blocked".</p>
<p>The cannon is two loops over the same ray. The first walks empty points as quiet moves. The second starts after the first piece it meets (the screen) and looks for the next piece beyond it:</p>
<pre><code class="language-ts">        case CANNON: {
          const rays = RAYS[s]!;
          for (let d = 0; d &lt; 4; d++) {
            const ray = rays[d]!, len = ray.length;
            let i = 0;
            for (; i &lt; len; i++) { const t = ray[i]!; if (b[t] !== 0) break; if (!capsOnly) out[at++] = s | (t &lt;&lt; 7); }
            for (i++; i &lt; len; i++) { const t = ray[i]!, q = b[t]!; if (q !== 0) { if (q &amp; opp) out[at++] = s | (t &lt;&lt; 7); break; } }   // over exactly one piece
          }
          break;
        }
</code></pre>
<p>The generator is pseudo-legal: it does not ask whether the mover's general is left attacked. Legality is checked by making the move and asking <code>inCheck</code>.</p>
<h2>The flying general is a chariot</h2>
<p>This is the decision I would copy first. Instead of a separate "generals facing" test, <code>inCheck</code> treats the enemy general as a chariot that only attacks along the file. It looks outward from the general being tested: the points a soldier or horse could attack from (each horse point paired with the leg that must be empty), then the four rays. The first piece on a ray is a chariot or a facing general. The second, behind a screen, is a cannon:</p>
<pre><code class="language-ts">  inCheck(c: Color): boolean {
    const b = this.board, k = this.kings[c]!, o = c ^ 1, base = (o + 1) &lt;&lt; 3;
    const pawn = base | PAWN, horse = base | HORSE, rook = base | ROOK, cannon = base | CANNON, king = base | KING;
    const pf = PAWN_FROM[o]![k]!;
    for (let i = 0; i &lt; pf.length; i++) if (b[pf[i]!] === pawn) return true;
    const hf = HORSE_FROM[k]!;
    for (let i = 0; i &lt; hf.length; i += 2) if (b[hf[i]!] === horse &amp;&amp; b[hf[i + 1]!] === 0) return true;
    const rays = RAYS[k]!;
    for (let d = 0; d &lt; 4; d++) {
      const ray = rays[d]!, len = ray.length;
      let i = 0;
      while (i &lt; len &amp;&amp; b[ray[i]!] === 0) i++;
      if (i &gt;= len) continue;
      const p = b[ray[i]!]!;
      if (p === rook || (p === king &amp;&amp; d &lt; 2)) return true;
      i++;
      while (i &lt; len &amp;&amp; b[ray[i]!] === 0) i++;
      if (i &lt; len &amp;&amp; b[ray[i]!] === cannon) return true;
    }
    return false;
  }
</code></pre>
<p><code>d &lt; 2</code> means up or down the file. A move that opens the file between the generals leaves the mover "in check" and is rejected by the same code that rejects any other move into check.</p>
<p>The generator is tested by perft from the opening position (44, 1,920, 79,666 and 3,290,240 positions at one to four plies) and against a second generator in the tests, "written slowly and plainly from the rules and sharing no code with src/sim", on every position of 60 random games: over 4,000 positions and 100 checks.</p>
<h2>Make and unmake, with a history the rules can read</h2>
<p><code>make()</code> updates everything incrementally: the board, the general's square, a 64-bit Zobrist hash kept as two 32-bit halves (<code>hashL</code>, <code>hashH</code>), and the evaluation. It also pushes onto parallel typed-array stacks: the move, what it captured, whether it gave check, and the hash before it. <code>unmake()</code> restores the hash from the stack. Those stacks do double duty: repetition and perpetual check are read straight from them, in the search and in the game alike.</p>
<h2>Repetition and perpetual check: what is actually implemented</h2>
<p>The rules of a whole game sit in <code>Match.judge()</code>, called after every move:</p>
<pre><code class="language-ts">  private judge(): void {
    const pos = this.pos, side = pos.side;
    if (!pos.hasLegalMove()) { this.end(other(side), pos.inCheck(side) ? 'checkmate' : 'stalemate'); return; }
    const c = this.cycle();
    if (c.count &gt;= 3) {
      if (c.selfAll &amp;&amp; !c.oppAll) this.end(other(side), 'perpetual');
      else if (c.oppAll &amp;&amp; !c.selfAll) this.end(side, 'perpetual');
      else this.end(-1, 'repetition');
      return;
    }
    if (pos.attackers(0) === 0 &amp;&amp; pos.attackers(1) === 0) { this.end(-1, 'material'); return; }
    if (this.quietPlies() &gt;= MOVE_LIMIT) this.end(-1, 'moveLimit');
  }
</code></pre>
<p><code>cycle()</code> walks back through the hash stack, but only to the last capture, since a position cannot recur across one. It counts earlier occurrences of the current position with the same side to move, then checks whether each side gave check with every one of its moves since the first occurrence. On the third occurrence, a side that checked every time while the other did not loses; anything else is a draw. A game is also drawn after 120 plies without a capture (<code>MOVE_LIMIT</code>, sixty moves each), or when neither side has a chariot, cannon, horse or soldier left, the pieces that can cross the river.</p>
<p>Be clear about what this is not. The tournament rules for xiangqi repetition also forbid perpetual chasing, endlessly attacking an unprotected piece without check, and have detailed cases for mixed check-and-chase cycles. This engine implements perpetual check only; a perpetual chase is ruled a draw by repetition. That is a simplification for a game against the computer, and it is the first thing I would add for a rules-strict implementation.</p>
<p>Inside the tree, <code>repStatus()</code> reads the same stacks, and a single repeat is enough, as usual in engines. <code>if (self &amp;&amp; !opp) return -BAN + ply;</code> scores a line where the side to move has checked forever as lost, and a plain repeat is worth <code>CONTEMPT = 20</code> below zero for the engine's side. These path-dependent scores are never stored in the transposition table. Before any search, the opponent also drops every root move that <code>forfeits()</code> says would lose on the spot. The player gets the same protection: such a move is refused with "Perpetual check is forbidden. Choose another move." whenever another move exists, and a warning appears after the second occurrence (<code>perpetualRisk()</code>).</p>
<h2>The search</h2>
<p><code>search.ts</code> is conventional: iterative deepening, alpha-beta with a principal variation (null-window searches after the first move), a transposition table, null-move pruning, late-move reductions, check extensions and a quiescence search over captures.</p>
<ul>
<li><strong>Transposition table:</strong> 2^18 entries in six parallel typed arrays, about 3.7 MB, indexed by the low hash half, verified against both halves, always replaced.</li>
<li><strong>Move ordering:</strong> hash move, then captures by most valuable victim and least valuable attacker, then two killers per ply, then a <code>from * 90 + to</code> history table that grows by <code>depth * depth</code> on a cutoff. Moves are picked one at a time, not sorted.</li>
<li><strong>Null move:</strong> the pass is searched at <code>depth - 3</code>, only when not in check and only for a side with enough pieces (<code>rooks * 2 + cannons + horses &gt;= 2</code>), because zugzwang is common in thin xiangqi endings.</li>
<li><strong>Late-move reductions:</strong> quiet, non-checking moves after the fourth lose 1 ply (2 after the fourteenth at depth 5 or more) and are searched again at full depth if they surprise.</li>
<li><strong>Mate and stalemate:</strong> a node with no legal move returns <code>-MATE + ply</code>. There is no separate stalemate case, because the rule makes both a loss.</li>
</ul>
<p>The evaluation is the weakest part. It is material plus piece-square tables, plus 3 for having the move: a soldier before the river is worth 100, a horse about 430, a cannon about 450, a chariot about 920. There is no mobility, no king safety beyond the general's table, and no idea of a hanging piece outside the quiescence search. I would add a cheap mobility and attacked-piece term next, kept only if it wins against the current version.</p>
<h2>Three levels from one search</h2>
<p>The levels are not different engines. They are numbers in <code>difficulty.ts</code>:</p>
<table>
<thead>
<tr>
<th>level</th>
<th>depth cap</th>
<th>positions per move</th>
<th>time in the browser</th>
<th>may stray</th>
<th>random move</th>
</tr>
</thead>
<tbody><tr>
<td>Easy</td>
<td>2 plies</td>
<td>6,000</td>
<td>500 ms</td>
<td>100</td>
<td>12%</td>
</tr>
<tr>
<td>Medium</td>
<td>4 plies</td>
<td>60,000</td>
<td>1,500 ms</td>
<td>24</td>
<td>2%</td>
</tr>
<tr>
<td>Hard</td>
<td>14 plies</td>
<td>1,200,000</td>
<td>4,000 ms</td>
<td>0</td>
<td>0%</td>
</tr>
</tbody></table>
<p>Hard's 14 plies are a ceiling: the search also stops at 1.2 million positions, at 4 seconds, and starts no new iteration once half the node budget is spent. I did not measure the depth it usually reaches. For the first 8 plies of a game from the usual start, Hard searches 5 plies and strays up to 14, so that no two games open alike.</p>
<p>"May stray" is the interesting part. For Easy, Medium and Hard's opening, <code>scoreAll()</code> scores every root move within the margin of the best, and the move is drawn with weights that fall off exponentially with the gap. A weaker level plays plausible second-best moves, not random ones:</p>
<pre><code class="language-ts">  if (margin &gt; 0) {
    const scored = s.scoreAll(m.pos, limits, margin, list);
    let best = -Infinity;
    let top = scored[0]!;
    for (const x of scored) if (x.score &gt; best) { best = x.score; top = x; }
    // a game that is decided is not strayed in: the quickest win, or the longest defence
    if (best &gt; WIN || best &lt; -WIN) return { move: top.move, score: top.score, depth, nodes: s.nodes, kind: 'best' };
    const w = scored.map((x) =&gt; Math.exp(-(best - x.score) / Math.max(1, margin / 2)));
    let r = rng.next() * w.reduce((a, b) =&gt; a + b, 0), pick = scored[scored.length - 1]!;
    for (let i = 0; i &lt; scored.length; i++) { r -= w[i]!; if (r &lt;= 0) { pick = scored[i]!; break; } }
    return { move: pick.move, score: pick.score, depth, nodes: s.nodes, kind: 'margin' };
  }
</code></pre>
<p>Once a forced mate is found, the level stops straying. The gaps were measured in self-play: a ladder of in-between levels played matches, and the scores became Elo differences, recorded as Medium about +1,389 and Hard about +2,086 above Easy (120 games). The source itself calls them rough, because neighbouring steps won 19 games of 20. Randomness is seeded (mulberry32), and a test checks that a seed replays the same game with the time limit off.</p>
<p>One weakness is plain: Easy's 12% random move is rolled before the search. On roughly one move in eight Easy plays a random legal move without looking, so it can miss a mate in one (the mate-in-one test sets <code>blunder: 0</code>). I would make the random move skip forced wins.</p>
<h2>Off the main thread, in a single file</h2>
<p>A one-file game has no <code>worker.js</code> to load. The game starts its own script a second time as a Web Worker:</p>
<pre><code class="language-ts">if (typeof document === 'undefined') {
  runWorker(self as unknown as Parameters&lt;typeof runWorker&gt;[0]);
} else {
  const source = (document.currentScript as HTMLScriptElement | null)?.textContent ?? null;
</code></pre>
<p>The page passes that text to <code>Engine</code>, which wraps it in a <code>Blob</code>, makes an object URL and starts <code>new Worker(url)</code>, so no second file is needed. Each request carries the starting FEN and the full move list, not just the current position. The worker replays the game, so it knows every repetition, and its transposition table survives from move to move. If no worker can be made, the search runs on the page after two animation frames, capped at 450 ms and 150,000 positions: weaker than Hard, but the board stays responsive. Leaving a game terminates a busy worker and starts a fresh one.</p>
<p>Take-backs need almost nothing from the engine: one pops two plies (yours and the reply) with the same <code>unmake()</code>, at most three times a game, only on your turn. The next request sends the shorter move list.</p>
<h2>What I would copy</h2>
<ol>
<li><strong>Treat the facing generals as a chariot along the file</strong> inside <code>inCheck</code>, and the rule costs nothing extra.</li>
<li><strong>Keep one history stack per position</strong> (move, capture, gave-check, hash before) and read repetition and perpetual check from it, in the search and in the game alike.</li>
<li><strong>Weaken by margin, not by noise.</strong> Score the moves near the best and draw from them with exponential weights.</li>
<li><strong>Test the generator against a second, naive one</strong> as well as perft.</li>
</ol>
<p>And what I would change: a chase rule, a richer evaluation, and an Easy level whose random moves never throw away a mate.</p>
<p><a href="https://antgames.ai/play/chinesechess/">CHINESE CHESS</a> runs in any modern browser: free, no ads, no purchases, no sign-up. The other games are on the <a href="https://antgames.ai/">Ant Games home page</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Auto battler without rarity: tuning 100 heroes to equal win rates by simulation]]></title><description><![CDATA[How we tuned 100 same-grade heroes with one dial each and 35 million simulated battles, with real code and numbers. Our own project.]]></description><link>https://antgamesai.hashnode.dev/auto-battler-without-rarity-tuning-100-heroes-to-equal-win-rates-by-simulation</link><guid isPermaLink="true">https://antgamesai.hashnode.dev/auto-battler-without-rarity-tuning-100-heroes-to-equal-win-rates-by-simulation</guid><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[simulation]]></category><category><![CDATA[#Game Design]]></category><dc:creator><![CDATA[Ant Games]]></dc:creator><pubDate>Sat, 14 Nov 2026 12:26:44 GMT</pubDate><content:encoded><![CDATA[<p>Most auto battlers sell their balance problem as a feature: rare heroes are stronger, so you pull for them. We wanted the opposite, so we built one with no rarity at all. <a href="https://antgames.ai/">Ant Games (antgames.ai)</a> makes free browser games (our own site: no ads, no purchases, no sign-up), and EQUAL HEROES ARENA has a hundred heroes in ten factions that all cost the same, never level up, and are supposed to win about as often as each other.</p>
<p>"Supposed to" is the hard part. You can't get there by feel with a hundred heroes and a thousand skills, so the numbers come from simulation. This post is how that works, with real snippets from the game's repository and numbers I re-measured while writing it.</p>
<p>A disclosure first: the game and its balance tooling were made with AI agents, on Ant Games' production line (the project was handed to the line as a finished base; agents audited and keep working on it). The game itself is fully in Chinese inside (title 同阶英雄竞技场); its page on our site is in English and Chinese: <a href="https://antgames.ai/play/equal-heroes-arena/">English</a>, <a href="https://antgames.ai/zh/play/equal-heroes-arena/">中文</a>. Everything below is plain JavaScript you can read in the repo's <code>tools/</code> folder.</p>
<p><img src="https://antgames.ai/share/equal-heroes-arena.jpg" alt="EQUAL HEROES ARENA" /></p>
<h2>The idea: one budget, one dial</h2>
<p>Every hero is built from the same role template (a tank has 1200 HP and 78 ATK, an assassin 700 HP and 128 ATK) and ten skills whose power is priced by a points budget (<code>js/core/budget.js</code>, where 1.0 is one basic attack). Authoring by budget gets you into the right neighbourhood, but not the right house.</p>
<p>So each hero gets exactly one balancing number, a <strong>dial</strong>. It is not a grade and the player never sees or changes it. It multiplies HP, ATK and DEF, and also the stat-independent numbers in the kit (shield size, heal percent, buff values), so one number moves everything the hero does:</p>
<pre><code class="language-js">HA.retune = function () {
  for (var n = 0; n &lt; HA.HEROES.length; n++) {
    var h = HA.HEROES[n], t = HA.TUNING &amp;&amp; HA.TUNING[h.id], f = (t &amp;&amp; t.mult) || 1;
    var slots = dialled[h.id] || (dialled[h.id] = dialSlots(h));
    for (var i = 0; i &lt; slots.length; i++) {
      var s = slots[i], sign = s.v &lt; 0 ? -1 : 1;
      var v = Math.round(Math.abs(s.v) * f / s.q) * s.q;   // authored value, scaled, snapped to its step
      v = Math.min(s.max, Math.max(s.min, v));            // never past the stat's own limits
      s.o[s.k] = sign * Math.round(v * 1000) / 1000;
    }
  }
};
</code></pre>
<p>Every call starts from the authored numbers, so the tuner can call it thousands of times with different dials. Because skill text in the game is generated from those numbers, the tooltip always says what the retuned skill really does.</p>
<h2>Step 1: make "a fair test" precise</h2>
<p>"Win rate" depends entirely on who the hero fights alongside. The tuner uses a sampler of random but sensible teams: a front line, usually a support, and no more than two of any back-line role.</p>
<pre><code class="language-js">plan.push(rng() &lt; 0.8 ? 'tank' : 'fighter');
plan.push(rng() &lt; 0.7 ? (plan[0] === 'tank' ? 'fighter' : 'tank') : null);
let needSupport = rng() &lt; 0.75;
// ...fill the rest from ['assassin','mage','ranger','support'], at most 2 of any
</code></pre>
<p>Two such teams fight with every hero on its standard build, and each hero's wins are credited to every hero on the winning team. Battles are fully deterministic given a seed (a small mulberry-style generator, <code>HA.makeRng</code>), so every number below can be reproduced.</p>
<h2>Step 2: pick the build before you tune the power</h2>
<p>A hero brings 6 of its 10 skills (4 actions, 2 passives), so "the hero" is really a family of builds. If you tune power against a bad build you'll boost a hero to compensate for a mistake. So the tool first finds each hero's standard build by greedy search plus single swaps, scored in 1,200 fixed scenarios (random allies, random foes, fixed seeds):</p>
<pre><code class="language-js">// greedy from nothing: which skill helps most next?
for (let step = 0; step &lt; H.LOADOUT_SIZE; step++) { /* try every legal slot, keep the best */ }
// then single swaps until none helps (greedy picks can block a better pair)
for (let round = 0; round &lt; 5; round++) { /* swap one skill; stop when the gain is under 0.002 */ }
</code></pre>
<p>The score of a scenario is half win/loss and half the HP fraction left, which is much less noisy than win/loss alone. The search also records each skill's value: what you lose by dropping it.</p>
<h2>Step 3: tune the dials by feedback</h2>
<p>The core loop is almost embarrassingly simple. Measure every hero's win rate, then nudge its dial against the error:</p>
<pre><code class="language-js">for (const id of ids) mult[id] *= Math.exp(-gain * (m.wr[id] - 0.5));
normalise();   // keep the geometric mean of all dials at 1, bounded to 0.45..2.0
</code></pre>
<p>The exponential keeps dials positive and symmetric; <code>normalise()</code> stops the whole roster drifting stronger together. The tool runs a few coarse passes, re-finds builds, then fine passes: 120,000 battles per pass across worker processes (the header comment quotes about 30,000 battles a second on a 32-thread machine).</p>
<h2>Step 4: equal win rate isn't enough</h2>
<p>This is the part I think is worth stealing. If every hero wins 50% of random teams, you can still have a game where nobody picks half of them. A hero can be average in random teams and useless in the best teams, or be in every winning team. So the tuner measures a second thing: for every hero, <strong>search for the best team that contains it</strong> (start from winners of an open self-play league, swap the hero into each seat, mutate, keep the best) and play those hundred teams against the league's best. That number is <code>top</code>. Each meta round then combines three readings into one dial update:</p>
<pre><code class="language-js">mult[id] *= Math.exp(-g * (G_TOP * (vS[i] - target)       // best team with this hero vs the league
                         + G_PICK * overPicked(uS[i])      // how many top line-ups field it
                         + G_RATE * (m.wr[id] - 0.5)));    // random-team win rate
</code></pre>
<p>with <code>G_RATE = 0.6, G_TOP = 0.5, G_PICK = 0.06</code>. A hero that is strong only next to certain partners ends up with a lower dial: weak alone, strong in its combo. The best team found for each hero then joins the league, so line-ups the league never reached by itself get their chance to take over. The full run is 30 meta rounds; the shipped table records 35.2 million battles.</p>
<h2>What it produced</h2>
<p>The output is a generated file, <code>js/data/tuning.js</code>, one line per hero (this is the real first row):</p>
<pre><code class="language-js">wisp: { mult: 1.144, wr: 0.529, top: 0.758, order: ['p3','u2','p2','a3','a1','a2','p1','a4','u1','p4'], val: { ... } },
</code></pre>
<p>I re-measured it with the game's own sampler and the real engine, 400,000 battles each way, one process (<code>scripts/article3-measure.cjs</code> in our SEO folder; about three minutes each):</p>
<table>
<thead>
<tr>
<th></th>
<th>as authored (every dial 1.0)</th>
<th>shipped tuning</th>
</tr>
</thead>
<tbody><tr>
<td>lowest hero</td>
<td>30.4% (mycorax)</td>
<td>42.7% (sylvara)</td>
</tr>
<tr>
<td>highest hero</td>
<td>71.4% (morwyn)</td>
<td>55.3% (nettle)</td>
</tr>
<tr>
<td>standard deviation</td>
<td>8.01 points</td>
<td>3.19 points</td>
</tr>
<tr>
<td>heroes outside 45-55%</td>
<td>49</td>
<td>12</td>
</tr>
<tr>
<td>average battle</td>
<td>9.03 rounds, 14.9% sudden death</td>
<td>8.24 rounds, 10.5% sudden death</td>
</tr>
</tbody></table>
<p>The dials that did it run from 0.638 to 1.416. The stored <code>top</code> values range from 60.9% to 86.1%: even the weakest hero's best team beats the league's best teams 60.9% of the time here, and no hero's best team falls under 45%.</p>
<h2>Where it falls short</h2>
<p>I'd rather tell you than have you find it. Three honest limits, all from our own audit (<code>docs/audits/balance.md</code>, 400,000 battles, written by an Opus-class agent that was told to mend nothing):</p>
<ul>
<li><strong>"Equal" means a band of about plus or minus 6 points, not 50.0%.</strong> Twelve heroes sit outside 45-55%; none is outside 40-60. The audit noted the tool could narrow this with a higher <code>G_RATE</code> or more passes. We have not.</li>
<li><strong>Balancing heroes did not balance the ladder.</strong> The rating numbers shown on the rival ladder (library line-ups) are on the wrong scale between 1400 and 2600: 400 points apart wins about 57%, where the rating formula says 91%. Fixing the hero dials never touched that, because it's a different mechanism (how rivals get rated).</li>
<li><strong>Combos add nothing at equal rating</strong> (50.0% in 200 matched pairs). They are already priced into the rating, so tuning combos up on that reading would have been a mistake.</li>
</ul>
<p>One thing it cleared: with the same line-ups on both sides, side A won 50.3% over 50,000 battles.</p>
<h2>What I would reuse</h2>
<ol>
<li><strong>One dial per entity, applied everywhere the entity's numbers live.</strong> Scaling only HP and ATK misses shields, heals and buffs.</li>
<li><strong>Fix the builds first.</strong> Otherwise the dial absorbs a bad build.</li>
<li><strong>Feedback in log space with a normaliser.</strong> Ten lines, and a few passes settle it.</li>
<li><strong>Measure two things: random-team win rate and best-team strength.</strong> The second one is what keeps a hero from being fair on paper and pointless in practice.</li>
<li><strong>Write the audit that contradicts you.</strong> Ours caught the rating scale, which the hero numbers could never have shown.</li>
</ol>
<p>To see the result, the game is free and runs in one page: <a href="https://antgames.ai/play/equal-heroes-arena/">EQUAL HEROES ARENA</a> (<a href="https://antgames.ai/zh/play/equal-heroes-arena/">中文页面</a>), or the <a href="https://antgames.ai/">Ant Games home page</a> for the other games. The interface is in Chinese.</p>
]]></content:encoded></item><item><title><![CDATA[Music and sound effects with no audio files: synthesising everything in Web Audio for browser games]]></title><description><![CDATA[How our free browser games make all music and sound effects in Web Audio, with no audio files. Our own project.]]></description><link>https://antgamesai.hashnode.dev/music-and-sound-effects-with-no-audio-files-synthesising-everything-in-web-audio-for-browser-games</link><guid isPermaLink="true">https://antgamesai.hashnode.dev/music-and-sound-effects-with-no-audio-files-synthesising-everything-in-web-audio-for-browser-games</guid><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Web Audio]]></category><dc:creator><![CDATA[Ant Games]]></dc:creator><pubDate>Sat, 14 Nov 2026 08:31:20 GMT</pubDate><content:encoded><![CDATA[<p>We make free browser games at <a href="https://antgames.ai/">Ant Games</a> (our own site: no ads, no purchases, no sign-up). None of them ships an audio file. Every sound effect and every piece of music is built in code with the Web Audio API, which keeps each game a single self-contained page and lets the music react to play.</p>
<p>This post covers the techniques we actually use, with snippets trimmed from the audio code of three of our games: JETSUN (plain JavaScript), STAR AGAINST THE DARK and SALLY PORT (TypeScript). It is not a Web Audio tutorial; it assumes you know what an <code>OscillatorNode</code> is.</p>
<h2>1. A tiny kit of voice helpers</h2>
<p>Almost every sound effect is built from the same few blocks. JETSUN wraps them in four functions: a swept oscillator, filtered noise, two-operator FM and a "musical note". The workhorse:</p>
<pre><code class="language-js">// percussive envelope: attack, decay to -30 dB over `dur`, 20 ms release
function perc(a, param, t, att, dur, vol) {
  a.env(param, t, [[0, 0.0001], [att, vol], [dur, vol * 0.03], [dur + 0.02, 0.0001]]);
}

function tone(a, t, out, type, f0, f1, dur, vol = 1, att = 0.002) {
  const g = a.gain(0, out);
  perc(a, g.gain, t, att, dur, vol);
  const o = a.osc(type, f0, t, dur, g);
  if (f1 !== f0) a.env(o.frequency, t, [[0, f0], [dur, f1]]);  // pitch sweep
  return o;
}
</code></pre>
<p>Two details matter. Envelopes ramp to <code>0.0001</code>, never to <code>0</code>, because exponential ramps cannot reach zero. And every helper takes the start time <code>t</code> explicitly instead of using "now", so a recipe can be placed anywhere on the audio clock. <code>a.osc()</code> creates the oscillator, starts it and schedules its stop, so nothing leaks.</p>
<p>A rapid-fire machine gun is then a swept square wave plus a short noise burst:</p>
<pre><code class="language-js">defineSound('p.vulcan', {
  vol: 0.3, minGap: 0.045,
  play(a, t, out, o) {
    const p = (o.pitch || 1) * R(0.96, 1.04);       // random pitch per shot
    tone(a, t, a.filter('lowpass', 2600, 0.7, out), 'square', 430 * p, 120 * p, 0.07, 0.8, 0.001);
    hiss(a, t, out, 'bandpass', 3200, 1400, 0.035, 0.5, 0.9, 0.001);
  },
});
</code></pre>
<p>The pitch jitter stops repeated shots from sounding like one sample on loop, and <code>minGap</code> stops a sound from stacking up when it is triggered every frame.</p>
<p>FM is just as short. The modulator's gain is the modulation depth, and it decays with the note, so the timbre goes from bright to dull the way a struck bell or a zap does:</p>
<pre><code class="language-js">const c = a.osc(type, fc, t, dur, g);                  // carrier
const depth = a.gain(fc * ratio * index);
a.env(depth.gain, t, [[0, fc * ratio * index], [dur, 1]]);
a.osc('sine', fc * ratio, t, dur, depth);              // modulator
depth.connect(c.frequency);                            // audio-rate frequency modulation
</code></pre>
<h2>2. One shared noise buffer</h2>
<p>Noise is everywhere: explosions, hi-hats, wind, shots. Instead of a buffer per sound, create two seconds of white noise once and share it:</p>
<pre><code class="language-js">const nb = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate), nd = nb.getChannelData(0);
for (let i = 0; i &lt; nd.length; i++) nd[i] = Math.random() * 2 - 1;
</code></pre>
<p>Each voice plays that buffer through its own filter. For endless wind or engine loops set <code>loop = true</code> and start at a random offset (<code>s.start(t, Math.random())</code>) so two loops never line up in phase. SALLY PORT does the same but fills its buffer from a seeded PRNG (<code>new Rng(99)</code>), so the noise is identical on every run, which matters for section 7.</p>
<h2>3. A reverb with no impulse-response file</h2>
<p>A convolution reverb normally needs a recorded impulse response. You can generate one: noise multiplied by a decay curve is a perfectly decent small hall.</p>
<pre><code class="language-js">const len = Math.floor(ctx.sampleRate * 2.2), ir = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch &lt; 2; ch++) {
  const d = ir.getChannelData(ch);
  for (let i = 0; i &lt; len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6);
}
reverb = ctx.createConvolver(); reverb.buffer = ir;
</code></pre>
<p>Independent noise per channel gives a wide stereo tail for free. Each sound declares how much it sends (<code>reverb: 0.3</code> for the railgun, nothing for the machine gun) and the engine wires a send gain to the shared convolver.</p>
<h2>4. A music sequencer that schedules ahead on the audio clock</h2>
<p><code>setInterval</code> is far too jittery to time notes. The standard answer is a look-ahead scheduler: a timer wakes up often, and each time schedules every note that falls inside the next fraction of a second, using exact <code>AudioContext</code> times. JETSUN's music kit does this:</p>
<pre><code class="language-js">tick(now) {  // called ~40 times a second
  if (this.t &lt; now - 0.1) while (this.t &lt; now + 0.02) this._next(); // stalled: skip along the grid, no burst
  while (this.t &lt; now + LOOK) { this._play(this.t); this._next(); } // LOOK = 0.25 s
}
_play(t) {
  if (this.step &amp; 1) t += this.swing;                  // swing the off-beat sixteenths
  for (const layer of this.layers) { /* play this step's event if the layer is on */ }
}
</code></pre>
<p>The "stalled" line matters. If a tab was in the background and the timer was throttled, <code>this.t</code> is far in the past, and a naive loop would fire hundreds of notes at once. Skipping along the grid without playing keeps the music in time.</p>
<p>Our tracks are data, not audio. A JETSUN tune is a string notation per bar plus a layer table:</p>
<pre><code class="language-js">const HOOK = "3 - - 5 8 - - - | 7 - - 6 5 - - - | 6 - - 8 3' - 9 - | ...";
bpm: 108, key: 50, scale: 'major',
layers: {
  kick:  { inst: 'drum', kit: 'kick', vol: 0.7, at: 0.38, pat: 'X.....x.X.......' },
  snare: { inst: 'drum', kit: 'snare', vol: 0.42, at: 0.42, pat: '....X.......X...' },
  lead:  { inst: 'lead', vol: 0.27, wave: 'sawtooth', wave2: 'square', det: 6, vib: 7, at: 0.33 },
}
</code></pre>
<p>Notes are scale degrees, not frequencies, so a track changes key by changing one number. The <code>at</code> field is the intensity at which a layer comes in: the game calls <code>setIntensity(0..1)</code> and the arrangement thickens (pad and bells when calm, drums and lead as danger rises) without restarting the track.</p>
<h2>5. Custom waveforms with <code>createPeriodicWave</code></h2>
<p>The four built-in oscillator types are limiting for pads. STAR AGAINST THE DARK builds its own from harmonic recipes: amplitude falling as <code>1 / h^power</code> is soft for a high power and bright for a low one, and dropping the even harmonics gives a hollow, reedy tone.</p>
<pre><code class="language-ts">const wave = (n: number, power: number, oddOnly: boolean): PeriodicWave =&gt; {
  const re = new Float32Array(n + 1), im = new Float32Array(n + 1);
  for (let h = 1; h &lt;= n; h++) im[h] = oddOnly &amp;&amp; h % 2 === 0 ? 0.12 / Math.pow(h, power) : 1 / Math.pow(h, power);
  return ctx.createPeriodicWave(re, im);
};
this.waves = [wave(10, 1.7, false), wave(14, 1.35, false), wave(15, 1.25, true), wave(20, 1.1, false)];
// later: oscillator.setPeriodicWave(this.waves[st.wave]);
</code></pre>
<p>Four recipes from soft to bright, and the music state picks one. No sample files, a handful of lines.</p>
<h2>6. The master chain: limiter, then a soft clip</h2>
<p>With dozens of voices the output clips unless you plan for it. STAR AGAINST THE DARK ends its chain with a compressor used as a limiter, a trim gain and a <code>WaveShaper</code> soft clip:</p>
<pre><code class="language-ts">master.connect(limiter).connect(trim).connect(clip).connect(out);
out.connect(ctx.destination);
</code></pre>
<p>Two notes from that file. First, a <code>DynamicsCompressorNode</code> applies automatic makeup gain, so the trim value is computed from the compressor's settings rather than guessed. Second, a Blink quirk noted in the code: the compressor starts fully closed and only opens while it is being fed, so the first sound after start-up lost its attack. The fix is to feed it an inaudible 20 Hz tone for a moment:</p>
<pre><code class="language-ts">const warm = ctx.createOscillator();
warm.frequency.value = 20;
const warmGain = ctx.createGain();
warmGain.gain.value = 1e-5;
warm.connect(warmGain).connect(limiter);
warm.start(); warm.stop(ctx.currentTime + WARMUP);
</code></pre>
<p>SALLY PORT and JETSUN use a simpler master (a compressor into the destination). Separate buses for music and effects, each with its own volume and a mute, are what make a game's settings screen work. SALLY PORT also has a <code>duck</code> gain on the music bus, eased with <code>setTargetAtTime(on ? 0.3 : 1, t, 0.15)</code>, so music drops while a menu is open.</p>
<h2>7. Test it offline, render it deterministically</h2>
<p>Because the graph is built on a plain <code>BaseAudioContext</code>, the same code runs on an <code>OfflineAudioContext</code>. STAR AGAINST THE DARK has an audio checker that renders each sound and each music state to a buffer with four output channels: the final output, and the mix just before the limiter, so you can see what the limiter did.</p>
<pre><code class="language-ts">const ctx = new OfflineAudioContext({ numberOfChannels: 4, length, sampleRate: 48000 });
const core = new AudioCore(ctx, { seed, sink });   // the same graph the realtime engine builds
</code></pre>
<p>Two ingredients make this useful: a seeded PRNG (the file uses mulberry32) in place of <code>Math.random()</code>, so a render is reproducible, and measurement of the result (peak, loudness, what the limiter removed), so a sound that is too quiet or clipping fails a check instead of waiting for a player to notice.</p>
<h2>8. Tie the visuals to the audio clock</h2>
<p>SALLY PORT has one more trick worth stealing. Its <code>beat()</code> method returns 1 on each beat, falling to 0 before the next, computed from <code>ctx.currentTime</code> rather than a frame counter:</p>
<pre><code class="language-ts">const ph = (ctx.currentTime - this.beatT) / this.beatDur;
</code></pre>
<p>Anything on screen that pulses (a light, a bar, a camera nudge) reads it and stays in time with what you hear, even if the frame rate stutters.</p>
<h2>Practical notes</h2>
<ul>
<li><strong>Unlock on a user gesture.</strong> Create or resume the context on the first <code>pointerdown</code> or <code>keydown</code>. JETSUN does both, and suspends the context when <code>document.hidden</code>.</li>
<li><strong>Seed anything you want to test.</strong> Use a seeded PRNG for sound design that has to be reproducible.</li>
<li><strong>Disconnect what you create.</strong> We track live sources and disconnect them when they end.</li>
<li><strong>Rate-limit every sound.</strong> A per-sound minimum gap costs one comparison and saves your mix.</li>
</ul>
<p>The trade-off is real: code-made audio costs design time, and it will not sound like a recorded orchestra. What it gives you is a game that is one file, loads instantly, runs offline, and whose music can respond to play in ways a fixed recording cannot.</p>
<p>To hear the results, <a href="https://antgames.ai/play/jetsun/">JETSUN</a> and <a href="https://antgames.ai/play/star-against-the-dark/">STAR AGAINST THE DARK</a> are playable in the browser, and the rest are on the <a href="https://antgames.ai/">Ant Games home page</a>. The site and each game's page are available in English and Simplified Chinese. Questions about any of the techniques above are welcome in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[One HTML page, no network requests: how our browser games ship]]></title><description><![CDATA[How the free Ant Games browser games ship as one HTML page with no network requests. Our own project.]]></description><link>https://antgamesai.hashnode.dev/one-html-page-no-network-requests-how-our-browser-games-ship</link><guid isPermaLink="true">https://antgamesai.hashnode.dev/one-html-page-no-network-requests-how-our-browser-games-ship</guid><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Ant Games]]></dc:creator><pubDate>Sat, 14 Nov 2026 07:11:19 GMT</pubDate><content:encoded><![CDATA[<p>We build a free browser-game site, <a href="https://antgames.ai">Ant Games</a> (disclosure: this is our own project). Every game on it follows a short contract that sounds restrictive and turned out to be a useful design constraint: <strong>one HTML page, no network requests, no asset files, and everything the player sees or hears is generated by code.</strong> This post describes what that means in practice, using facts from our own repository.</p>
<h2>The contract</h2>
<p>From the production guide every game on the site is built to:</p>
<ul>
<li><strong>One self-contained page.</strong> <code>dist/index.html</code> holds everything. It makes no network request of any kind: no CDN, no web font, no analytics, no <code>fetch</code>. It runs from a double-click (<code>file://</code>).</li>
<li><strong>No asset files beside the page.</strong> Models, textures, sprites, icons, sound effects and music are generated by code. Fonts are system font stacks.</li>
<li><strong>TypeScript, strict.</strong> Sources are bundled with esbuild into the single page. The only runtime dependency allowed is <code>three</code>, and only for a 3D game.</li>
<li><strong>A deterministic, headless simulation.</strong> Game logic lives apart from rendering: fixed timestep, all randomness from a seeded generator, no DOM, no clock, no <code>Math.random()</code>. The same seed and inputs give the same game in the browser and in Node.</li>
<li><strong>No</strong> <code>alert</code><strong>,</strong> <code>confirm</code> <strong>or</strong> <code>prompt</code>, and saves are versioned JSON under a per-game <code>localStorage</code> prefix; a save that does not parse is replaced, never trusted.</li>
<li><strong>Languages:</strong> The site and every game page are in English and Chinese; Equal Heroes Arena is fully in Chinese, and the others are in English for now.</li>
</ul>
<p>And on the business side: no ads, no purchases, no sign-up, nothing imitating selling.</p>
<h2>Why a single file</h2>
<p>A page with no outside requests can't break because a CDN moved, can't leak the player's visit to a third party, and can be archived, emailed or opened offline. It also forces honesty about size: our built SALLY PORT page is 822,010 bytes and HAIRS BREADTH's is 734,817 bytes, with all code, graphics and audio inside.</p>
<h2>Graphics: WebGL2</h2>
<p><a href="https://antgames.ai/play/jetsun/">JETSUN</a>, a side-scrolling shoot-'em-up in full 3D, uses three.js on WebGL2 with an HDR render target, MSAA, bloom, a custom post pass and ACES tone mapping, with procedural lofted models and detail maps. Its game.json says it plainly: "Every model, texture, effect, sound and note of music is generated in code." Other games bring their own renderer rather than a library; the guide's rule is to borrow machinery (WebGL helpers, batching, bloom chain, input, save plumbing) but never a game's identity.</p>
<p>Where code is bad at something, such as rigs and baked light, the project allows Blender as an offline tool. Its output is baked into the page as data, so the single-file, no-requests rule still holds.</p>
<h2>Audio: synthesised, not sampled</h2>
<p>Every effect is synthesised, and music comes from a step sequencer rather than audio files. <a href="https://antgames.ai/play/sallyport/">SALLY PORT</a>, a gate-defence game with 50 levels and 50 bosses, lists "62 sound effects and no asset files" and a distinct music track for each of its five places. Each game has separate volumes and mute.</p>
<h2>A deterministic simulation pays for itself</h2>
<p>Because the simulation is seeded and headless, the same code runs in Node for tests and bot balance runs, and a seeded daily run comes almost for free: <a href="https://antgames.ai/play/hairsbreadth/">HAIRS BREADTH</a>, a three-lane runner through a jungle temple, ships The Escape (five zones, with an ending), Endless and a seeded Daily Run.</p>
<h2>What we would tell you</h2>
<p>Constraints that look like limits (no network, no files, no clock) mostly turned into free testability and portability. The cost is real: everything, from sound to models, becomes code you maintain.</p>
<p>You can try the result at <a href="https://antgames.ai">antgames.ai</a>, where games are free, without ads or purchases.</p>
]]></content:encoded></item></channel></rss>