A real KataGo network (b6c96) playing full-size Go in the browser. 1.16 MB of weights, compiled to WGSL by tensorgrad.
---
format: typebulb/v1
name: KataGo
---
**code.tsx**
```tsx
import {
Module, Conv2d, compileForward, isWebGPUAvailable,
add, mul, matmul, relu, reshape, concat, mean, maxPool2d,
type Tensor,
} from 'tensorgrad'
import {
App, Component, div, section, h1, p, span, strong, em,
button, label, canvas, inputSelect,
} from 'domeleon'
// ==========================================================================
// Go tactics
// ==========================================================================
// The two Go algorithms KataGo feeds its net as inputs, because a 6-block
// convnet cannot derive either one: ladder search (spatial features 14-17) and
// Benson's unconditional life (features 18,19).
//
// Ported from lightvector/KataGo cpp/game/board.cpp —
// Board::searchIsLadderCaptured / searchIsLadderCapturedAttackerFirst2Libs
// Board::calculateArea / calculateAreaForPla (https://senseis.xmp.net/?BensonsAlgorithm)
//
// Fidelity matters more than elegance here: the net was trained against these
// exact outputs, including their approximations. Where KataGo gives up early or
// answers conservatively, so must we, or the planes disagree in precisely the
// tactical positions they exist to describe.
const EMPTY = 0
const BLACK = 1
const WHITE = -1
const MAX_LADDER_SEARCH_NODE_BUDGET = 25000 // board.cpp, same constant
type Color = number
// Orthogonal neighbours of a point on a `size` x `size` board.
function neighbors(loc: number, size: number): number[] {
const y = (loc / size) | 0, x = loc % size
const out: number[] = []
if (y > 0) out.push(loc - size)
if (y < size - 1) out.push(loc + size)
if (x > 0) out.push(loc - 1)
if (x < size - 1) out.push(loc + 1)
return out
}
interface Group { stones: number[]; liberties: number[] }
// The chain at `start` and its liberties; both empty for an empty point.
function group(board: Int8Array, size: number, start: number): Group {
const color = board[start]!
const stones: number[] = []
const liberties: number[] = []
if (color === EMPTY) return { stones, liberties }
const seen = new Set<number>([start])
const libSeen = new Set<number>()
const stack = [start]
while (stack.length) {
const cur = stack.pop()!
stones.push(cur)
for (const n of neighbors(cur, size)) {
const c = board[n]!
if (c === EMPTY) {
if (!libSeen.has(n)) { libSeen.add(n); liberties.push(n) }
} else if (c === color && !seen.has(n)) {
seen.add(n); stack.push(n)
}
}
}
return { stones, liberties }
}
// Simple ko: exactly one stone captured by a lone stone left with exactly one
// liberty — the only shape whose recapture would immediately repeat the position.
const koPoint = (placed: Group, captured: number[]): number =>
captured.length === 1 && placed.stones.length === 1 && placed.liberties.length === 1
? captured[0]! : -1
// ---------------------------------------------------------------------------
// A mutable board supporting play/undo, for the ladder search
// ---------------------------------------------------------------------------
interface PlayRecord {
loc: number
captured: number[]
prevKo: number
}
class TacticalBoard {
readonly size: number
readonly colors: Int8Array
koLoc: number
constructor(colors: Int8Array, size: number, koLoc = -1) {
this.size = size
this.colors = colors.slice()
this.koLoc = koLoc
}
chain(loc: number): Group { return group(this.colors, this.size, loc) }
numLiberties(loc: number): number { return this.chain(loc).liberties.length }
// Empty neighbours of a point (KataGo's getNumImmediateLiberties).
immediateLiberties(loc: number): number {
let n = 0
for (const a of neighbors(loc, this.size)) if (this.colors[a] === EMPTY) n++
return n
}
// Play without legality checks beyond suicide; returns null if the move is
// suicidal (and therefore not a candidate).
play(loc: number, pla: Color): PlayRecord | null {
const rec: PlayRecord = { loc, captured: [], prevKo: this.koLoc }
this.colors[loc] = pla
for (const n of neighbors(loc, this.size)) {
if (this.colors[n] !== -pla) continue
const c = this.chain(n)
if (c.liberties.length > 0) continue
for (const s of c.stones) { this.colors[s] = EMPTY; rec.captured.push(s) }
}
const placed = this.chain(loc)
if (placed.liberties.length === 0) {
// Undo the suicide before reporting it.
for (const s of rec.captured) this.colors[s] = -pla
this.colors[loc] = EMPTY
return null
}
this.koLoc = koPoint(placed, rec.captured)
return rec
}
undo(rec: PlayRecord): void {
const pla = this.colors[rec.loc]!
this.colors[rec.loc] = EMPTY
for (const s of rec.captured) this.colors[s] = -pla
this.koLoc = rec.prevKo
}
// Capturing moves that would gain the chain at loc liberties: precisely the
// lone liberties of adjacent opponent chains in atari.
libertyGainingCaptures(loc: number): number[] {
const opp = -this.colors[loc]!
const out: number[] = []
const done = new Set<number>()
for (const stone of this.chain(loc).stones) {
for (const a of neighbors(stone, this.size)) {
if (this.colors[a] !== opp || done.has(a)) continue
const c = this.chain(a)
if (c.liberties.length !== 1) continue
for (const s of c.stones) done.add(s)
for (const lib of c.liberties) if (!out.includes(lib)) out.push(lib)
}
}
return out
}
// Move-ordering heuristic: for each adjacent friendly chain, libs*2 - 3.
heuristicConnectionLibertiesX2(loc: number, pla: Color): number {
let total = 0
for (const a of neighbors(loc, this.size)) {
if (this.colors[a] !== pla) continue
const libs = this.numLiberties(a)
if (libs > 1) total += libs * 2 - 3
}
return total
}
}
const isAdjacent = (a: number, b: number, size: number): boolean => {
const d = Math.abs(a - b)
if (d === size) return true
return d === 1 && ((a / size) | 0) === ((b / size) | 0)
}
// ---------------------------------------------------------------------------
// Ladder search
// ---------------------------------------------------------------------------
interface LadderState { nodes: number; exhausted: boolean }
// Is the chain at `loc` captured, assuming both sides play the ladder out?
// `defenderFirst` = the defender moves next (used when the group is in atari).
function ladderSearch(b: TacticalBoard, loc: number, isDefender: boolean, st: LadderState): boolean {
if (st.exhausted) return false
if (st.nodes++ >= MAX_LADDER_SEARCH_NODE_BUDGET) { st.exhausted = true; return false }
const pla = b.colors[loc]! // defender's colour
const opp = -pla
const chain = b.chain(loc)
const libs = chain.liberties.length
// Base cases, straight from board.cpp.
if (!isDefender && libs <= 1) return true // attacker to move, already atari -> dead
if (!isDefender && libs >= 3) return false // attacker to move, too many libs -> alive
if (isDefender && libs >= 2) return false // defender got out
// A defender facing a ko point is treated as escaping: KataGo deliberately
// refuses to claim ladders that depend on ko, and this also rules out loops.
if (isDefender && b.koLoc >= 0) return false
let moves: number[]
if (isDefender) {
moves = [...b.libertyGainingCaptures(loc), ...chain.liberties]
} else {
moves = [...chain.liberties]
if (moves.length !== 2) return false // caller guarantees 2 libs here
const l0 = b.immediateLiberties(moves[0]!)
const l1 = b.immediateLiberties(moves[1]!)
// If the two liberties aren't adjacent, filling one can't reduce the other,
// so an escape giving 3+ liberties settles that branch immediately.
if (!isAdjacent(moves[0]!, moves[1]!, b.size)) {
// l0 is how many liberties the DEFENDER would gain by escaping at
// liberty 0. If that escape wins, the attacker is forced to occupy
// liberty 0 itself — keep that move, not the other one.
if (l0 >= 3 && l1 >= 3) return false
else if (l0 >= 3) moves = [moves[0]!]
else if (l1 >= 3) moves = [moves[1]!]
}
// Order by liberties gained plus potential connections. With a finite node
// budget, ordering can change the answer, so it is not merely a speedup.
if (moves.length > 1) {
const s0 = l0 * 2 + b.heuristicConnectionLibertiesX2(moves[0]!, pla)
const s1 = l1 * 2 + b.heuristicConnectionLibertiesX2(moves[1]!, pla)
if (s1 > s0) moves = [moves[1]!, moves[0]!]
}
}
for (const move of moves) {
if (b.colors[move] !== EMPTY) continue
if (move === b.koLoc) continue
const rec = b.play(move, isDefender ? pla : opp)
if (!rec) continue // suicide, not a candidate
// The defender's stone may have been captured outright.
const gone = b.colors[loc] !== pla
const captured = gone ? true : ladderSearch(b, loc, !isDefender, st)
b.undo(rec)
if (isDefender && !captured) return false // defender found an escape
if (!isDefender && captured) return true // attacker found a kill
}
// Defender exhausted every try -> caught. Attacker exhausted every try -> alive.
return isDefender
}
// For a chain with exactly 2 liberties: does an attacker move first work, and
// which first moves are the working ones? (feature 17)
function ladderAttackerFirst(b: TacticalBoard, loc: number, workingMoves: number[]): boolean {
const pla = b.colors[loc]!
const opp = -pla
const libs = b.chain(loc).liberties
if (libs.length !== 2) return false
// Try BOTH liberties unconditionally. The attacker heuristics inside
// ladderSearch (adjacency quitouts, move ordering) belong to deeper attacker
// nodes, not here — applying them at the root prunes moves KataGo always
// tries, and silently loses ladders.
const works = [false, false]
for (let i = 0; i < 2; i++) {
const move = libs[i]!
if (move === b.koLoc) continue // isLegal(move, opp) rejects the ko point
const rec = b.play(move, opp) // suicide is never relevant to ladders
if (!rec) continue
// The recursive call is defenderFirst, which clears the ko at its root:
// KataGo assumes every ko works for the defender.
b.koLoc = -1
works[i] = b.colors[loc] !== pla
? true
: ladderSearch(b, loc, true, { nodes: 0, exhausted: false })
b.undo(rec) // restores the previous ko
}
if (!works[0] && !works[1]) return false
workingMoves.length = 0
if (works[0]) workingMoves.push(libs[0]!)
if (works[1]) workingMoves.push(libs[1]!)
return true
}
// Visit every stone whose chain is in a losing ladder, solving once per chain.
// Mirrors KataGo's iterLadders: only chains with 1 or 2 liberties are candidates.
function iterLadders(
colors: Int8Array,
size: number,
koLoc: number,
visit: (loc: number, workingMoves: number[]) => void,
): void {
const b = new TacticalBoard(colors, size, koLoc)
const solved = new Map<number, { caught: boolean; workingMoves: number[] }>()
for (let loc = 0; loc < size * size; loc++) {
const color = colors[loc]!
if (color === EMPTY) continue
const chain = b.chain(loc)
const libs = chain.liberties.length
if (libs !== 1 && libs !== 2) continue
const head = Math.min(...chain.stones) // canonical id for the chain
let entry = solved.get(head)
if (!entry) {
const workingMoves: number[] = []
let caught: boolean
if (libs === 1) {
// In atari: the defender replies first, and all kos are assumed to work
// for the defender, so clear the ko point at the root.
const saved = b.koLoc
b.koLoc = -1
caught = ladderSearch(b, loc, true, { nodes: 0, exhausted: false })
b.koLoc = saved
} else {
caught = ladderAttackerFirst(b, loc, workingMoves)
}
entry = { caught, workingMoves }
solved.set(head, entry)
}
if (entry.caught) visit(loc, entry.workingMoves)
}
}
// ---------------------------------------------------------------------------
// Benson's algorithm — unconditional life
// ---------------------------------------------------------------------------
interface Region {
locs: number[]
vitalFor: Set<number> // pla chain ids this region is vital for
internalSpaces: number // points not adjacent to any pla stone, capped at 2
containsOpp: boolean
bordersNonPassAlive: boolean
}
// Marks `result` with points owned by `pla` under Benson's, with
// safeBigTerritories and unsafeBigTerritories both enabled (KataGo's setting
// for area scoring with no tax — nninputs.cpp fillRowV7).
function calculateAreaForPla(
colors: Int8Array, size: number, pla: Color, suicideLegal: boolean, result: Int8Array,
): void {
const area = size * size
const opp = -pla
// 1. Chains of pla.
const chainOf = new Int32Array(area).fill(-1)
const chains: number[][] = []
let atLeastOnePla = false
for (let loc = 0; loc < area; loc++) {
if (colors[loc] !== pla) continue
atLeastOnePla = true
if (chainOf[loc] >= 0) continue
const id = chains.length
const stones: number[] = []
const stack = [loc]
chainOf[loc] = id
while (stack.length) {
const cur = stack.pop()!
stones.push(cur)
for (const n of neighbors(cur, size)) {
if (colors[n] === pla && chainOf[n] < 0) { chainOf[n] = id; stack.push(n) }
}
}
chains.push(stones)
}
if (!atLeastOnePla) return
// 2. Maximal regions of empty-or-opponent points.
const regionOf = new Int32Array(area).fill(-1)
const regions: Region[] = []
for (let loc = 0; loc < area; loc++) {
if (colors[loc] === pla || regionOf[loc] >= 0) continue
const id = regions.length
const locs: number[] = []
const stack = [loc]
regionOf[loc] = id
while (stack.length) {
const cur = stack.pop()!
locs.push(cur)
for (const n of neighbors(cur, size)) {
if (colors[n] !== pla && regionOf[n] < 0) { regionOf[n] = id; stack.push(n) }
}
}
// 3. Vitality: a region is vital to a chain when every qualifying point of
// the region touches it. With suicide illegal only empty points qualify.
let vital: Set<number> | null = null
let internalSpaces = 0
let containsOpp = false
for (const p of locs) {
if (colors[p] === opp) containsOpp = true
const adjacentChains = new Set<number>()
for (const n of neighbors(p, size)) if (colors[n] === pla) adjacentChains.add(chainOf[n]!)
if (adjacentChains.size === 0 && internalSpaces < 2) internalSpaces++
if (!suicideLegal && colors[p] !== EMPTY) continue
if (vital === null) vital = adjacentChains
else for (const c of [...vital]) if (!adjacentChains.has(c)) vital.delete(c)
}
regions.push({
locs, vitalFor: vital ?? new Set(), internalSpaces, containsOpp, bordersNonPassAlive: false,
})
}
// 4. Eliminate: a chain needs >= 2 vital regions to survive; killing a chain
// spoils every region it borders, which may kill further chains.
const vitalCount = new Int32Array(chains.length)
for (const r of regions) for (const c of r.vitalFor) vitalCount[c]!++
const killed = new Array<boolean>(chains.length).fill(false)
for (;;) {
let killedAnything = false
for (let id = 0; id < chains.length; id++) {
if (killed[id] || vitalCount[id]! >= 2) continue
killed[id] = true
killedAnything = true
for (const stone of chains[id]!) {
for (const n of neighbors(stone, size)) {
const r = regionOf[n]!
if (r < 0 || regions[r]!.bordersNonPassAlive) continue
regions[r]!.bordersNonPassAlive = true
for (const c of regions[r]!.vitalFor) vitalCount[c]!--
}
}
}
if (!killedAnything) break
}
// 5. Mark surviving chains, then their territory.
for (let id = 0; id < chains.length; id++) {
if (!killed[id]) for (const s of chains[id]!) result[s] = pla
}
for (const r of regions) {
const shouldMark =
(r.internalSpaces <= 1 && !r.bordersNonPassAlive) ||
(!r.containsOpp && !r.bordersNonPassAlive) // safeBigTerritories
if (shouldMark) {
for (const p of r.locs) result[p] = pla // unconditional, may overwrite
} else if (!r.containsOpp) { // unsafeBigTerritories
for (const p of r.locs) if (result[p] === EMPTY) result[p] = pla
}
}
}
// Ownership of every point: pass-alive territory for both colours, then any
// remaining point takes the colour of the stone sitting on it.
function calculateArea(colors: Int8Array, size: number, suicideLegal = false): Int8Array {
const result = new Int8Array(size * size)
calculateAreaForPla(colors, size, BLACK, suicideLegal, result)
calculateAreaForPla(colors, size, WHITE, suicideLegal, result)
for (let loc = 0; loc < size * size; loc++) {
if (result[loc] === EMPTY) result[loc] = colors[loc]!
}
return result
}
// ==========================================================================
// Positions and input encoding
// ==========================================================================
// KataGo V7 input encoding (22 spatial + 19 global), NCHW.
//
// Ported from lightvector/KataGo cpp/neuralnet/nninputs.cpp `NNInputs::fillRowV7`.
// Model version 8 consumes input version 7 (nninputs.h:85-86).
//
// The planes that need real Go algorithms — ladders (14-17) and pass-alive
// territory (18,19) — come from the tactics above.
//
// Scope note: this targets `rules: chinese` — SCORING_AREA, KO_SIMPLE, TAX_NONE,
// no button, suicide illegal (rules.cpp:202-211). Under those rules globals
// 6-13 and 15-17 are all identically zero, and feature 6 is the simple ko point
// only, with no superko history to track.
const SPATIAL_FEATURES = 22
const GLOBAL_FEATURES = 19
const KOMI_CLIP_RADIUS = 20 // nninputs.h:21
interface Move {
pla: Color
loc: number // -1 = pass
}
interface Position {
size: number
board: Int8Array // size*size, row-major from the top (KataGo's y=0 is the top row)
toPlay: Color
koLoc: number // simple-ko forbidden point, -1 if none
history: Move[]
prevBoards: Int8Array[] // most recent first; ladder planes 15,16 need them
komi: number // points to White
}
const PASS = -1
const newPosition = (size: number, komi = 7.5): Position => ({
size,
board: new Int8Array(size * size),
toPlay: BLACK,
koLoc: -1,
history: [],
prevBoards: [],
komi,
})
// The boards KataGo runs the ladder search over for planes 15 and 16. It falls
// back to the current board when that much history isn't available, so an
// opening position reports the same ladders on all three planes rather than none.
function historyBoards(pos: Position, included: number): [Int8Array, Int8Array] {
const prev = included < 1 ? pos.board : (pos.prevBoards[0] ?? pos.board)
const prevPrev = included < 2 ? prev : (pos.prevBoards[1] ?? prev)
return [prev, prevPrev]
}
// ---------------------------------------------------------------------------
// Board mechanics
// ---------------------------------------------------------------------------
// Play assuming legality has been checked. Returns the successor position,
// leaving `pos` untouched — the undo stack and the search tree both keep
// references to it.
function playAssumeLegal(pos: Position, loc: number): Position {
const next: Position = {
size: pos.size,
board: pos.board.slice(),
toPlay: -pos.toPlay,
koLoc: -1,
history: [...pos.history, { pla: pos.toPlay, loc }],
prevBoards: [pos.board, ...pos.prevBoards].slice(0, 2),
komi: pos.komi,
}
if (loc === PASS) return next
next.board[loc] = pos.toPlay
const captured: number[] = []
for (const n of neighbors(loc, pos.size)) {
if (next.board[n] !== -pos.toPlay) continue
const g = group(next.board, pos.size, n)
if (g.liberties.length > 0) continue
for (const s of g.stones) { next.board[s] = EMPTY; captured.push(s) }
}
next.koLoc = koPoint(group(next.board, pos.size, loc), captured)
return next
}
// Suicide is illegal under chinese rules (multiStoneSuicideLegal = false), so a
// move is legal exactly when it leaves the played stone with a liberty: an empty
// neighbour, a friendly chain with a liberty to spare, or an enemy chain whose
// last liberty is this point and which therefore comes off the board.
function isLegal(pos: Position, loc: number): boolean {
if (loc === PASS) return true
if (pos.board[loc] !== EMPTY) return false
if (loc === pos.koLoc) return false
for (const n of neighbors(loc, pos.size)) {
const c = pos.board[n]!
if (c === EMPTY) return true
const libs = group(pos.board, pos.size, n).liberties.length
if (c === pos.toPlay ? libs > 1 : libs === 1) return true
}
return false
}
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
interface Encoded {
spatial: Float32Array // [22, size, size] NCHW
global: Float32Array // [19]
}
function encode(pos: Position): Encoded {
const { size, board, toPlay } = pos
const area = size * size
const spatial = new Float32Array(SPATIAL_FEATURES * area)
const global = new Float32Array(GLOBAL_FEATURES)
const opp = -toPlay
const set = (feature: number, loc: number, value = 1) => { spatial[feature * area + loc] = value }
// ch 0: on-board. ch 1,2: own/opponent stone. ch 3,4,5: exactly 1/2/3 liberties.
const libCache = new Map<number, number>()
for (let loc = 0; loc < area; loc++) {
set(0, loc)
const stone = board[loc]!
if (stone === EMPTY) continue
set(stone === toPlay ? 1 : 2, loc)
let libs = libCache.get(loc)
if (libs === undefined) {
const g = group(board, size, loc)
libs = g.liberties.length
for (const s of g.stones) libCache.set(s, libs)
}
if (libs === 1) set(3, loc)
else if (libs === 2) set(4, loc)
else if (libs === 3) set(5, loc)
}
// ch 6: ko ban. Under KO_SIMPLE with no encore this is just the ko point;
// ch 7,8 are encore-only and stay zero.
if (pos.koLoc >= 0) set(6, pos.koLoc)
// ch 9-13 and globals 0-4: the last five moves, most recent first.
// KataGo walks the chain only while players strictly alternate and stops at
// the first break — so a doubled move truncates history rather than shifting it.
const hist = pos.history
const expected = [opp, toPlay, opp, toPlay, opp]
let included = 0
for (let back = 1; back <= 5; back++) {
const move = hist[hist.length - back]
if (!move || move.pla !== expected[back - 1]) break
included = back
if (move.loc === PASS) global[back - 1] = 1
else set(8 + back, move.loc)
}
// ch 14-17: ladders, on the current board and the two before it. ch 17 marks
// the attacker's working first moves, and only for opponent chains that still
// have more than one liberty.
iterLadders(board, size, pos.koLoc, (loc, workingMoves) => {
set(14, loc)
if (board[loc] === opp && (libCache.get(loc) ?? group(board, size, loc).liberties.length) > 1) {
for (const m of workingMoves) set(17, m)
}
})
const prevBoards = historyBoards(pos, included)
iterLadders(prevBoards[0]!, size, -1, loc => set(15, loc))
iterLadders(prevBoards[1]!, size, -1, loc => set(16, loc))
// ch 18,19: current territory under Benson's, from the mover's perspective.
const areaOwner = calculateArea(board, size, false)
for (let loc = 0; loc < area; loc++) {
if (areaOwner[loc] === toPlay) set(18, loc)
else if (areaOwner[loc] === opp) set(19, loc)
}
// ch 20,21 are second-encore starting stones — always zero in normal play.
// Globals. Under chinese rules 6-13 and 15-17 are all zero:
// 6,7 KO_SIMPLE writes nothing 9 SCORING_AREA writes nothing
// 8 suicide illegal 10,11 TAX_NONE writes nothing
// 12,13 encorePhase 0 15,16 no playoutDoublingAdvantage
// 17 hasButton false
let selfKomi = toPlay === WHITE ? pos.komi : -pos.komi
const clip = area + KOMI_CLIP_RADIUS
selfKomi = Math.min(clip, Math.max(-clip, selfKomi))
global[5] = selfKomi / 20
// global 14: would a pass end the phase? Under area scoring in the main phase
// that means the previous move was already a pass.
const last = hist[hist.length - 1]
global[14] = last && last.loc === PASS ? 1 : 0
// global 18: triangular komi-parity wave (nninputs.cpp, fillRowV7 tail).
// Only written for area scoring, which is our case.
const drawableKomisAreEven = area % 2 === 0
const komiFloor = drawableKomisAreEven
? Math.floor(selfKomi / 2) * 2
: Math.floor((selfKomi - 1) / 2) * 2 + 1
const delta = Math.min(2, Math.max(0, selfKomi - komiFloor))
global[18] = delta < 0.5 ? delta : delta < 1.5 ? 1 - delta : delta - 2
return { spatial, global }
}
// ==========================================================================
// The network
// ==========================================================================
// KataGo b6c96 (model version 8) as a tensorgrad graph.
//
// Reference: lightvector/KataGo — python/katago/train/model_pytorch.py for the
// forward pass and global-pool formulas, cpp/neuralnet/desc.cpp for the weight
// format. Version 8 predates configurable activations, so every actv is ReLU
// (desc.cpp:382-401). The exported batchnorms all carry mean=0, var=1, so each
// "norm" is a plain per-channel affine — and since KataGo's convs have no bias,
// these affines ARE the biases.
const TRUNK = 96
const MID = 96
const REG = 64 // regular path width inside a gpool block
const GPOOL = 32 // gpool path width
const PHEAD = 32 // policy head channels
const VHEAD = 32 // value head channels
const V2 = 64
// ---------------------------------------------------------------------------
// Modules
// ---------------------------------------------------------------------------
// Per-channel affine, shaped [C,1,1] so trailing-axis broadcast lines it up
// against [B,C,H,W] without a reshape at every call site.
class Norm extends Module {
scale: Tensor
bias: Tensor
constructor(channels: number) {
super()
this.scale = this.param([channels, 1, 1])
this.bias = this.param([channels, 1, 1])
}
}
const norm = (n: Norm, x: Tensor): Tensor => add(mul(x, n.scale), n.bias)
class OrdinaryBlock extends Module {
norm1: Norm
w1: Conv2d
norm2: Norm
w2: Conv2d
constructor() {
super()
this.norm1 = new Norm(TRUNK)
this.w1 = new Conv2d(TRUNK, MID, 3, { padding: 1, bias: false })
this.norm2 = new Norm(MID)
this.w2 = new Conv2d(MID, TRUNK, 3, { padding: 1, bias: false })
}
}
class GpoolBlock extends Module {
norm1: Norm
w1a: Conv2d // regular path
w1b: Conv2d // pooled path
norm1b: Norm
w1r: Tensor // [3*GPOOL, REG] — pooled stats back into the regular path
norm2: Norm
w2: Conv2d
constructor() {
super()
this.norm1 = new Norm(TRUNK)
this.w1a = new Conv2d(TRUNK, REG, 3, { padding: 1, bias: false })
this.w1b = new Conv2d(TRUNK, GPOOL, 3, { padding: 1, bias: false })
this.norm1b = new Norm(GPOOL)
this.w1r = this.param([3 * GPOOL, REG])
this.norm2 = new Norm(REG)
this.w2 = new Conv2d(REG, TRUNK, 3, { padding: 1, bias: false })
}
}
type Block = OrdinaryBlock | GpoolBlock
class KataGo extends Module {
conv1 = new Conv2d(SPATIAL_FEATURES, TRUNK, 5, { padding: 2, bias: false })
ginputw = this.param([GLOBAL_FEATURES, TRUNK])
blocks: Block[]
trunkNorm = new Norm(TRUNK)
// policy head
p1 = new Conv2d(TRUNK, PHEAD, 1, { bias: false })
g1 = new Conv2d(TRUNK, PHEAD, 1, { bias: false })
g1norm = new Norm(PHEAD)
matmulg2w = this.param([3 * PHEAD, PHEAD])
p1norm = new Norm(PHEAD)
p2 = new Conv2d(PHEAD, 1, 1, { bias: false })
matmulpass = this.param([3 * PHEAD, 1])
// value head
v1 = new Conv2d(TRUNK, VHEAD, 1, { bias: false })
v1norm = new Norm(VHEAD)
v2w = this.param([3 * VHEAD, V2])
v2b = this.param([V2])
v3w = this.param([V2, 3]) // win / loss / draw logits
v3b = this.param([3])
sv3w = this.param([V2, 4]) // score-related outputs
sv3b = this.param([4])
ownership = new Conv2d(VHEAD, 1, 1, { bias: false })
// `kinds` mirrors the manifest's block list so the tree matches the checkpoint.
constructor(kinds: readonly ('ordinary' | 'gpool')[]) {
super()
this.blocks = kinds.map(k => (k === 'gpool' ? new GpoolBlock() : new OrdinaryBlock()))
}
}
// ---------------------------------------------------------------------------
// Forward
// ---------------------------------------------------------------------------
// KataGo scales its pooled means by board size so one net serves every board.
// model_pytorch.py:505-514 (trunk/policy) and :534-540 (value head).
// On a full board the mask is all ones, so the mask terms drop out and the
// max reduces to a plain global max — which maxPool2d over the whole board is.
const poolConstants = (boardSize: number) => {
const area = boardSize * boardSize
const s = Math.sqrt(area) - 14
return { area, scale2: s / 10, scale3: (s * s) / 100 - 0.1 }
}
const makeForward = (boardSize: number) => {
const { area, scale2, scale3 } = poolConstants(boardSize)
// [B,C,H,W] -> [B,3C] : mean, size-scaled mean, max
const gpool = (x: Tensor, channels: number): Tensor => {
const avg = mean(reshape(x, [-1, channels, area]), -1)
const peak = reshape(maxPool2d(x, boardSize), [-1, channels])
return concat([avg, mul(avg, scale2), peak], -1)
}
// The value head pools three mean-based stats — no max term. Easy to get
// wrong by assuming symmetry with the trunk pool; it isn't symmetric.
const vgpool = (x: Tensor, channels: number): Tensor => {
const avg = mean(reshape(x, [-1, channels, area]), -1)
return concat([avg, mul(avg, scale2), mul(avg, scale3)], -1)
}
// Broadcast a per-channel vector back across the board.
const spread = (v: Tensor, channels: number): Tensor => reshape(v, [-1, channels, 1, 1])
return (m: KataGo, { spatial, global: globalIn }: { spatial: Tensor; global: Tensor }): Tensor => {
let h = add(m.conv1.fwd(spatial), spread(matmul(globalIn, m.ginputw), TRUNK))
for (const block of m.blocks) {
const pre = relu(norm(block.norm1, h))
if (block instanceof GpoolBlock) {
const regular = block.w1a.fwd(pre)
const pooled = gpool(relu(norm(block.norm1b, block.w1b.fwd(pre))), GPOOL)
const biased = add(regular, spread(matmul(pooled, block.w1r), REG))
h = add(h, block.w2.fwd(relu(norm(block.norm2, biased))))
} else {
const hidden = relu(norm(block.norm2, block.w1.fwd(pre)))
h = add(h, block.w2.fwd(hidden))
}
}
const trunk = relu(norm(m.trunkNorm, h))
// policy: p1 gets a bias derived from g1's pooled stats; pass comes off the
// same pooled vector, which is why the pass logit needs no spatial path.
const g1 = relu(norm(m.g1norm, m.g1.fwd(trunk)))
const g1pool = gpool(g1, PHEAD)
const p1 = add(m.p1.fwd(trunk), spread(matmul(g1pool, m.matmulg2w), PHEAD))
const points = reshape(m.p2.fwd(relu(norm(m.p1norm, p1))), [-1, area])
const pass = matmul(g1pool, m.matmulpass)
// value: raw logits and raw ownership — KataGo applies softmax/tanh
// downstream (model_pytorch.py:2844-2847), so we keep the model faithful
// and post-process in JS.
const v1 = relu(norm(m.v1norm, m.v1.fwd(trunk)))
const v2 = relu(add(matmul(vgpool(v1, VHEAD), m.v2w), m.v2b))
const value = add(matmul(v2, m.v3w), m.v3b)
const score = add(matmul(v2, m.sv3w), m.sv3b)
const ownership = reshape(m.ownership.fwd(v1), [-1, area])
// One output, one readback: [points | pass | value(3) | score(4) | ownership]
return concat([points, pass, value, score, ownership], -1)
}
}
// Where each head starts inside the packed output row.
const outputLayout = (boardSize: number) => {
const area = boardSize * boardSize
return {
points: 0,
pass: area,
value: area + 1,
score: area + 4,
ownership: area + 8,
width: area * 2 + 8,
} as const
}
// ---------------------------------------------------------------------------
// Weight import
// ---------------------------------------------------------------------------
interface TensorSpec {
shape: number[]
dtype: 'float32' | 'int8'
offset: number
scaleOffset?: number // int8 only: one f32 scale per groupSize weights
groupSize?: number
}
interface Manifest {
format: string
architecture: { numBlocks: number; blocks: ('ordinary' | 'gpool')[] }
tensors: Record<string, TensorSpec>
totalBytes: number
}
// convert.mjs emits KataGo's own layer names; the Module tree derives different
// ones from its field paths. This is the only place the two vocabularies meet.
function toParamNames(manifest: Manifest, blob: ArrayBuffer): Record<string, Float32Array> {
const params: Record<string, Float32Array> = {}
// INT8 is a shipping format, not a compute format: tensorgrad is f32-only, so
// everything expands here. The quantization buys download size (1.05 MB vs
// 4.11 MB), nothing at inference time.
const read = (katagoName: string) => {
const t = manifest.tensors[katagoName]
if (!t) throw new Error(`checkpoint is missing ${katagoName}`)
const count = t.shape.reduce((a, b) => a * b, 1)
if (t.dtype === 'float32') return new Float32Array(blob, t.offset, count)
if (t.scaleOffset === undefined) throw new Error(`${katagoName}: int8 tensor with no scales`)
const groupSize = t.groupSize ?? count
const groups = Math.ceil(count / groupSize)
const q = new Int8Array(blob, t.offset, count)
const scales = new Float32Array(blob, t.scaleOffset, groups)
const out = new Float32Array(count)
for (let i = 0; i < count; i++) out[i] = q[i]! * scales[(i / groupSize) | 0]!
return out
}
const copy = (dst: string, src: string) => { params[dst] = read(src) }
const copyNorm = (dst: string, src: string) => {
copy(`${dst}.scale`, `${src}.scale`)
copy(`${dst}.bias`, `${src}.bias`)
}
copy('conv1.W', 'conv1')
copy('ginputw', 'ginputw')
manifest.architecture.blocks.forEach((kind, i) => {
const src = `rconv${i + 1}`
const dst = `blocks.${i}`
copyNorm(`${dst}.norm1`, `${src}/norm1`)
copyNorm(`${dst}.norm2`, `${src}/norm2`)
copy(`${dst}.w2.W`, `${src}/w2`)
if (kind === 'gpool') {
copy(`${dst}.w1a.W`, `${src}/w1a`)
copy(`${dst}.w1b.W`, `${src}/w1b`)
copyNorm(`${dst}.norm1b`, `${src}/norm1b`)
copy(`${dst}.w1r`, `${src}/w1r`)
} else {
copy(`${dst}.w1.W`, `${src}/w1`)
}
})
copyNorm('trunkNorm', 'trunk/norm')
copy('p1.W', 'p1/intermediate_conv/w')
copy('g1.W', 'g1/w')
copyNorm('g1norm', 'g1/norm')
copy('matmulg2w', 'matmulg2w')
copyNorm('p1norm', 'p1/norm')
copy('p2.W', 'p2/w')
copy('matmulpass', 'matmulpass')
copy('v1.W', 'v1/w')
copyNorm('v1norm', 'v1/norm')
copy('v2w', 'v2/w')
copy('v2b', 'v2/b')
copy('v3w', 'v3/w')
copy('v3b', 'v3/b')
copy('sv3w', 'sv3/w')
copy('sv3b', 'sv3/b')
copy('ownership.W', 'vownership/w')
return params
}
// Fetch once from the network, thereafter from CacheStorage. The checkpoint is
// 1.16 MB and immutable, so re-downloading it on every open is pure latency.
// Falls back to a plain fetch wherever the Cache API isn't available.
async function cachedFetch(url: string): Promise<ArrayBuffer> {
const download = async (): Promise<ArrayBuffer> => {
// One retry after a beat: a transient error on the first visit otherwise kills the
// page for good, when the file is immutable and cached forever after one success.
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`)
return await res.arrayBuffer()
} catch (e) {
if (attempt >= 1) throw e
await new Promise(r => setTimeout(r, 1000))
}
}
}
try {
const cache = await caches.open('katago-b6c96')
const hit = await cache.match(url)
if (hit) return hit.arrayBuffer()
const buf = await download()
try { await cache.put(url, new Response(buf)) } catch { /* caching is an optimisation */ }
return buf
} catch {
return download()
}
}
async function loadKataGo(baseUrl: string, boardSize: number) {
const [manifestBuf, blob] = await Promise.all([
cachedFetch(`${baseUrl}/katago-b6c96.json`),
cachedFetch(`${baseUrl}/katago-b6c96.bin`),
])
const manifest: Manifest = JSON.parse(new TextDecoder().decode(manifestBuf)) as Manifest
if (blob.byteLength !== manifest.totalBytes) throw new Error('truncated checkpoint')
const net = await compileForward({
model: new KataGo(manifest.architecture.blocks),
forward: makeForward(boardSize),
inputs: {
spatial: [null, SPATIAL_FEATURES, boardSize, boardSize],
global: [null, GLOBAL_FEATURES],
},
})
// uploadParams is strict both ways, so surface a mismatch as a readable diff
// rather than whatever error the library raises first.
const params = toParamNames(manifest, blob)
const expected = new Set(net.paramNames as readonly string[])
const missing = [...expected].filter(n => !(n in params))
const extra = Object.keys(params).filter(n => !expected.has(n))
if (missing.length || extra.length) {
throw new Error(`param mismatch — missing: ${missing.join(', ') || 'none'}; unexpected: ${extra.join(', ') || 'none'}`)
}
await net.uploadParams(params)
return { net, layout: outputLayout(boardSize) }
}
// ==========================================================================
// play
// ==========================================================================
const BOARD_SIZE = 19
const AREA = BOARD_SIZE * BOARD_SIZE
const KOMI = 7.5
const PAD_RATIO = 22 / 30
const HOSHI = [3, 9, 15]
// Low: on a 361-point board even a modest temperature spreads enough weight
// onto bad moves that the games fall apart (winrate swinging 50% -> 99% in
// three plies). This keeps them recognisably Go while still diverging run to run.
const DEMO_TEMP = 0.15
// Only the attract loop's opening is sampled, so the page doesn't replay one
// identical game forever. A Swap continues a real game and plays properly from
// the first move.
const DEMO_OPENING_PLIES = 8
const DEMO_MOVE_MS = 350
// KataGo resigns rather than grinding out a lost game — Go players resign far
// more often than chess players, and a bot that never does is a chore. There is
// no matching button for you: with no rating and no opponent waiting, resigning
// is just New Game with extra steps.
const RESIGN_WINRATE = 0.04
const RESIGN_MIN_PLY = 30
const ATTRACT_STATUS = 'KataGo is playing itself. Click the board to play.'
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
// A frame — or 50ms, whichever comes first. requestAnimationFrame never fires
// at all in a backgrounded tab, and awaiting one there parks startup forever.
const nextFrame = () => new Promise(r => {
const go = () => r(null)
requestAnimationFrame(go)
setTimeout(go, 50)
})
// ---------------------------------------------------------------------------
// Strength
// ---------------------------------------------------------------------------
// Two knobs, not one. Search only ever makes the net stronger, and its floor is
// raw policy: the whole network, playing its single best guess, which is what
// this bulb used to be. So everything below that floor has to come from move
// *selection*: price a shortlist of the net's own candidates and deliberately
// take one that gives up a target number of points. That loses coherently.
// Temperature, the other obvious lever, instead plays well and then randomly
// self-destructs.
//
// The names are a plain relative ladder rather than kyu/dan or 'club', which
// would be claims about human rank that nothing here measures.
interface Skill {
visits: number // 0 = no search
candidates: number // soft play: how many policy moves to price
targetLoss: number // soft play: points to hand over
}
const SKILLS = {
easiest: { visits: 0, candidates: 24, targetLoss: 8 },
easy: { visits: 0, candidates: 16, targetLoss: 3 },
medium: { visits: 0, candidates: 0, targetLoss: 0 },
hard: { visits: 400, candidates: 0, targetLoss: 0 },
// 1200 is the budget KataGo's own published ratings are measured at. Costs
// ~1.5 ms/visit here, so ~1.8 s a move, which is well inside what a Go player
// expects a bot to take. Search is bounded by the JS encoder rather than the
// GPU, so this scales with the CPU, not the graphics card.
hardest: { visits: 1200, candidates: 0, targetLoss: 0 },
} satisfies Record<string, Skill>
type SkillName = keyof typeof SKILLS
const SKILL_NAMES = ['easiest', 'easy', 'medium', 'hard', 'hardest'] as const
type OverlayKind = 'ownership' | 'policy' | 'none'
const OVERLAY_NAMES = ['ownership', 'policy', 'none'] as const
interface Evaluation {
policy: Float32Array // legal-masked probabilities, length AREA+1
ownership: Float32Array // tanh'd, Black's perspective
moverWin: number // the net's own frame: P(win) for the side to move
blackWinrate: number
blackLead: number
ms: number
}
interface Choice { move: number; e: Evaluation; ms: number; visits: number }
// Greedy self-play is deterministic, so the same game would replay forever.
// A little temperature makes each run different without wrecking the play.
function sampleFrom(probs: Float32Array, temperature: number): number {
const idx: number[] = []
const w: number[] = []
let total = 0
for (let i = 0; i <= AREA; i++) {
if (probs[i]! < 0) continue
const v = Math.pow(probs[i]!, 1 / temperature)
if (v <= 0) continue
idx.push(i); w.push(v); total += v
}
if (!idx.length) return AREA
let r = Math.random() * total
for (let i = 0; i < idx.length; i++) { r -= w[i]!; if (r <= 0) return idx[i]! }
return idx[idx.length - 1]!
}
function argmaxPolicy(probs: Float32Array): number {
let best = AREA, bestP = -Infinity
for (let i = 0; i <= AREA; i++) if (probs[i]! > bestP) { bestP = probs[i]!; best = i }
return best
}
// ---------------------------------------------------------------------------
// Search tree
// ---------------------------------------------------------------------------
// PUCT with virtual loss, so several leaves can be gathered into one batched
// dispatch instead of paying batch-1 latency per visit. A node's statistics are
// stored in ITS OWN mover's frame, so a parent reads a child as `1 - Q` — get
// that backwards and the search confidently picks the worst move available.
//
// Everything in this block is tree arithmetic and touches no net; the descent
// that feeds it is Engine.search below.
const CPUCT = 1.1
const FPU = 0.2 // first-play urgency reduction for unvisited children
const MAX_DEPTH = 60
interface MctsNode {
pos: Position
n: number
w: number // summed value, in pos.toPlay's frame
vl: number // descents currently in flight
prior: Float32Array | null // null until expanded
value: number | null // net value, cached so terminals re-use it
terminal: boolean
children: Map<number, MctsNode>
}
const endsGame = (p: Position): boolean => {
const h = p.history
return h.length >= 2 && h[h.length - 1]!.loc === PASS && h[h.length - 2]!.loc === PASS
}
const makeNode = (p: Position): MctsNode => ({
pos: p, n: 0, w: 0, vl: 0, prior: null, value: null,
terminal: endsGame(p), children: new Map(),
})
function selectChild(node: MctsNode): number {
const prior = node.prior!
const parentQ = node.n > 0 ? node.w / node.n : 0.5
const sqrtN = Math.sqrt(Math.max(1, node.n + node.vl))
let best = -1
let bestScore = -Infinity
for (let i = 0; i <= AREA; i++) {
const p = prior[i]!
if (p < 0) continue // illegal
const child = node.children.get(i)
const cn = child ? child.n + child.vl : 0
// The virtual loss counts as a virtual WIN for the child, which is what
// makes an in-flight branch unattractive to whoever is choosing above it.
const q = child && cn > 0 ? 1 - (child.w + child.vl) / cn : parentQ - FPU
const score = q + CPUCT * p * sqrtN / (1 + cn)
if (score > bestScore) { bestScore = score; best = i }
}
return best
}
function descend(root: MctsNode): { path: MctsNode[]; leaf: MctsNode } {
const path: MctsNode[] = []
let node = root
for (;;) {
path.push(node)
node.vl += 1
if (node.terminal || node.prior === null || path.length > MAX_DEPTH) break
const idx = selectChild(node)
if (idx < 0) break
let child = node.children.get(idx)
if (!child) {
child = makeNode(playAssumeLegal(node.pos, idx === AREA ? PASS : idx))
node.children.set(idx, child)
}
node = child
}
return { path, leaf: node }
}
// Walk back to the root, flipping perspective at every ply and releasing the
// virtual loss laid down on the way in.
function backup(path: MctsNode[], leafValue: number): void {
let v = leafValue
for (let i = path.length - 1; i >= 0; i--) {
const node = path[i]!
node.vl -= 1
node.n += 1
node.w += v
v = 1 - v
}
}
const bestRootMove = (root: MctsNode): number => {
let best = -1, bestN = -1
for (const [move, child] of root.children) {
if (child.n > bestN) { bestN = child.n; best = move }
}
return best
}
// ---------------------------------------------------------------------------
// Engine — everything that touches the net
// ---------------------------------------------------------------------------
// The batch axis is dynamic, so N positions cost one dispatch — but tensorgrad
// specialises a pipeline per batch SHAPE, and each new shape costs seconds to
// compile. Measured cold, a lone batch-2 call took 1957 ms. So every batched run
// here fills exactly BATCH slots and pads: only two pipelines ever exist (1 and
// BATCH) and each compile is paid once. Letting the batch size follow the work
// instead stalls for ~2 s every time a new size shows up.
//
// Encoding does not batch. That stays ~1 ms of CPU per position, and it is what
// actually bounds search — not the net.
const BATCH = 8
const SPATIAL_STRIDE = SPATIAL_FEATURES * AREA
type Compiled = Awaited<ReturnType<typeof loadKataGo>>
// One object for the compiled net, the layout of the row it returns, and every
// question you can ask it: evaluate a position, evaluate a batch, search, choose
// a move. It only exists once it is usable — `load` doesn't resolve until the
// pipeline it needs is compiled — so nothing inside ever re-checks whether the
// net is up. `Game` holds the single nullable reference and answers that
// question for the whole page, which is why the word `null` appears nowhere
// below.
class Engine {
#net: Compiled['net']
#layout: Compiled['layout']
private constructor(c: Compiled) {
this.#net = c.net
this.#layout = c.layout
}
static async load(baseUrl: string, boardSize: number): Promise<Engine> {
const engine = new Engine(await loadKataGo(baseUrl, boardSize))
// Only the single-position shape is needed to start playing, so that is all
// `load` waits behind. The batched shape costs seconds to compile and
// nothing on screen needs it yet — `warm` takes it in the background.
await engine.#runRows([newPosition(boardSize, KOMI)], 1)
return engine
}
// The batched pipeline is only reached by search and by the weakened settings.
// Compiling it off to one side rather than on first use puts that stall
// wherever the caller chooses to put it — in practice the attract loop, where
// a skipped beat is invisible — instead of mid-game on the move after you
// change the strength.
async warm(): Promise<void> {
await sleep(1200) // let the page settle first
const t0 = performance.now()
await this.#runRows([newPosition(BOARD_SIZE, KOMI)], BATCH)
tb.log(`batch pipeline warmed in ${(performance.now() - t0).toFixed(0)} ms`)
}
// ---- asking the net ----
// One row of the packed output. `base` is the row offset, so this reads a
// batched run as readily as a single position — search needs the batched form
// and the UI needs the single one, and the two must not drift apart.
#decodeRow(p: Position, out: Float32Array, base: number, ms: number): Evaluation {
const layout = this.#layout
const black = p.toPlay === BLACK
const logitAt = (i: number) =>
i === AREA ? out[base + layout.pass]! : out[base + layout.points + i]!
// Mask illegal moves, then softmax over what remains — KataGo's convention.
const probs = new Float32Array(AREA + 1)
const legal: boolean[] = []
for (let i = 0; i < AREA; i++) legal.push(isLegal(p, i))
legal.push(true)
let max = -Infinity
for (let i = 0; i <= AREA; i++) if (legal[i] && logitAt(i) > max) max = logitAt(i)
let total = 0
for (let i = 0; i <= AREA; i++) {
if (!legal[i]) { probs[i] = -1; continue }
const v = Math.exp(logitAt(i) - max)
probs[i] = v
total += v
}
for (let i = 0; i <= AREA; i++) if (probs[i]! >= 0) probs[i]! /= total
const moverWin = 1 / (1 + Math.exp(-(out[base + layout.value]! - out[base + layout.value + 1]!)))
const own = new Float32Array(AREA)
const sign = black ? 1 : -1
for (let i = 0; i < AREA; i++) own[i] = sign * Math.tanh(out[base + layout.ownership + i]!)
// sv3 is [scoreMean, scoreMeanSq, lead, varTimeLeft], and KataGo scales the
// score outputs by 20 before reporting. Calibrated against the reference
// rather than assumed: -0.7701 -> -15.4430, -0.7458 -> -14.9224.
const lead = out[base + layout.score + 2]! * 20 * sign
return {
policy: probs,
ownership: own,
moverWin,
blackWinrate: black ? moverWin : 1 - moverWin,
blackLead: lead,
ms,
}
}
async #runRows(list: Position[], slots: number): Promise<Evaluation[]> {
if (list.length === 0) return []
const spatial = new Float32Array(slots * SPATIAL_STRIDE)
const global = new Float32Array(slots * GLOBAL_FEATURES)
for (let i = 0; i < list.length; i++) {
const enc = encode(list[i]!)
spatial.set(enc.spatial, i * SPATIAL_STRIDE)
global.set(enc.global, i * GLOBAL_FEATURES)
}
// Padding slots repeat the last real position rather than being re-encoded:
// their output is discarded, and the encoder is the expensive half.
const last = list.length - 1
for (let i = list.length; i < slots; i++) {
spatial.copyWithin(i * SPATIAL_STRIDE, last * SPATIAL_STRIDE, (last + 1) * SPATIAL_STRIDE)
global.copyWithin(i * GLOBAL_FEATURES, last * GLOBAL_FEATURES, (last + 1) * GLOBAL_FEATURES)
}
const t0 = performance.now()
const r = await this.#net.run({ spatial, global })
const ms = performance.now() - t0
if (r.kind !== 'completed') return []
const width = this.#layout.width
return list.map((p, i) => this.#decodeRow(p, r.output, i * width, ms / list.length))
}
async evaluateBatch(list: Position[]): Promise<Evaluation[]> {
if (list.length === 1) return this.#runRows(list, 1)
const out: Evaluation[] = []
for (let i = 0; i < list.length; i += BATCH) {
const chunk = list.slice(i, i + BATCH)
const rows = await this.#runRows(chunk, BATCH)
if (rows.length !== chunk.length) return out
out.push(...rows)
}
return out
}
async evaluate(p: Position): Promise<Evaluation | null> {
return (await this.evaluateBatch([p]))[0] ?? null
}
// ---- searching ----
async search(rootPos: Position, visits: number): Promise<{ root: MctsNode; rootEval: Evaluation } | null> {
const rootEval = await this.evaluate(rootPos)
if (!rootEval) return null
const root = makeNode(rootPos)
root.prior = rootEval.policy
root.value = rootEval.moverWin
root.n = 1
root.w = rootEval.moverWin
let done = 1
while (done < visits) {
const want = Math.min(BATCH, visits - done)
const pending: { path: MctsNode[]; leaf: MctsNode }[] = []
for (let i = 0; i < want; i++) {
const d = descend(root)
// A depth cut-off or a revisited terminal already has a value; those cost
// nothing and must not go to the GPU.
if (d.leaf.value !== null) { backup(d.path, d.leaf.value); done += 1 }
else pending.push(d)
}
if (pending.length === 0) continue
const evals = await this.evaluateBatch(pending.map(d => d.leaf.pos))
if (evals.length !== pending.length) {
for (const d of pending) backup(d.path, 0.5) // release the virtual losses
return { root, rootEval }
}
for (let i = 0; i < pending.length; i++) {
const { path, leaf } = pending[i]!
const ev = evals[i]!
leaf.value = ev.moverWin
if (!leaf.terminal) leaf.prior = ev.policy
backup(path, ev.moverWin)
done += 1
}
}
return { root, rootEval }
}
// ---- choosing a move ----
// Price the net's own top candidates and take the one closest to a target
// score loss. The shortlist is policy-ordered, so even the discarded moves are
// moves the net considered — the result plays sensible-looking Go and is simply
// behind, rather than playing well and then throwing a group away.
async #softMove(p: Position, skill: Skill): Promise<{ move: number; e: Evaluation } | null> {
const e = await this.evaluate(p)
if (!e) return null
const order: number[] = []
for (let i = 0; i <= AREA; i++) if (e.policy[i]! >= 0) order.push(i)
order.sort((a, b) => e.policy[b]! - e.policy[a]!)
// Pass survives the shortlist only when the net genuinely wants it. A weak
// setting that stumbles into a pass hands over the game for nothing.
const top = order.filter((m, i) => m !== AREA || i === 0).slice(0, skill.candidates)
if (top.length <= 1) return { move: top[0] ?? AREA, e }
const evs = await this.evaluateBatch(top.map(m => playAssumeLegal(p, m === AREA ? PASS : m)))
if (evs.length !== top.length) return { move: top[0]!, e }
const sign = p.toPlay === BLACK ? 1 : -1
const leads = evs.map(x => x.blackLead * sign) // onto the mover's frame
const target = Math.max(...leads) - skill.targetLoss
let pick = 0
let bestDist = Infinity
for (let i = 0; i < leads.length; i++) {
const d = Math.abs(leads[i]! - target)
if (d < bestDist) { bestDist = d; pick = i }
}
return { move: top[pick]!, e }
}
async chooseMove(p: Position, skill: Skill): Promise<Choice | null> {
const t0 = performance.now()
if (skill.visits > 0) {
const r = await this.search(p, skill.visits)
if (!r) return null
const move = bestRootMove(r.root)
return {
move: move < 0 ? argmaxPolicy(r.rootEval.policy) : move,
e: r.rootEval, ms: performance.now() - t0, visits: skill.visits,
}
}
if (skill.candidates > 0) {
const r = await this.#softMove(p, skill)
if (!r) return null
return { move: r.move, e: r.e, ms: performance.now() - t0, visits: 0 }
}
const e = await this.evaluate(p)
if (!e) return null
return { move: argmaxPolicy(e.policy), e, ms: performance.now() - t0, visits: 0 }
}
}
// ---------------------------------------------------------------------------
// Ending a game
// ---------------------------------------------------------------------------
// Area score straight off the net's ownership head. At a finished position
// every point is claimed by someone and a dead group sits inside enemy
// territory, so thresholding ownership does dead-stone removal for free — which
// is the hard part of scoring Go and the reason most engines punt on it.
//
// This is the net's count, not a rules-exact one: there is no dead-stone
// negotiation, and a game stopped early will be scored on an unsettled board.
function scoreFromOwnership(own: Float32Array): string {
let black = 0
for (let i = 0; i < AREA; i++) if (own[i]! > 0) black++
const lead = black - (AREA - black) - KOMI
return lead > 0 ? `B+${lead.toFixed(1)}` : `W+${(-lead).toFixed(1)}`
}
// ---------------------------------------------------------------------------
// Game record
// ---------------------------------------------------------------------------
interface GameRecord {
pos: Position
mode: 'auto' | 'play'
humanColor: Color
swapped: boolean
result: string
}
// SGF FF[4]. A point is its column letter then its row letter, both counting
// from 'a' with row 'a' at the top — which is exactly how `loc` is laid out, so
// nothing flips. A pass is an empty value.
function toSgf(r: GameRecord): string {
const letter = (n: number) => String.fromCharCode(97 + n)
const point = (loc: number) =>
loc === PASS ? '' : letter(loc % BOARD_SIZE) + letter(Math.floor(loc / BOARD_SIZE))
// After a Swap neither side held one colour for the whole game, so naming the
// players would be a fiction. The attract loop is KataGo on both sides.
const names =
r.mode === 'auto' ? 'PB[KataGo b6c96]PW[KataGo b6c96]'
: r.swapped ? ''
: r.humanColor === BLACK ? 'PB[Human]PW[KataGo b6c96]'
: 'PB[KataGo b6c96]PW[Human]'
const moves = r.pos.history
.map(m => `;${m.pla === BLACK ? 'B' : 'W'}[${point(m.loc)}]`)
.join('')
return `(;GM[1]FF[4]CA[UTF-8]AP[tensorgrad kata-go]SZ[${BOARD_SIZE}]` +
`KM[${KOMI}]RU[Chinese]${names}${r.result ? `RE[${r.result}]` : ''}${moves})`
}
function downloadSgf(sgf: string, plies: number): void {
const url = URL.createObjectURL(new Blob([sgf], { type: 'application/x-go-sgf' }))
const a = document.createElement('a')
a.href = url
a.download = `katago-${plies}-moves.sgf`
a.click()
// The click is already dispatched, but revoking in the same tick has raced in
// some browsers, so let the frame finish first.
setTimeout(() => URL.revokeObjectURL(url), 0)
}
// ==========================================================================
// The page
// ==========================================================================
//
// Four components, split by what they own rather than by where they appear.
// `Game` is the model: the position, the loop that drives the engine, and the
// line of text under the board. It has no view of its own. `Board` is the
// canvas — painted rather than rendered, so it owns a drawing context and a
// geometry no view can express. `Controls` is the row above it. `Root` is the
// page, and holds the three.
//
// Nothing here keeps a copy of anything another one owns: the buttons read
// `game`, the two dropdowns bind straight onto `game.skill` and
// `board.overlay`, and the board paints from `game`. So there is no state to
// push between them and nothing that can disagree — an update anywhere
// re-renders the tree from whatever the owners now say.
// What a child may reach for. Deliberately the two components and nothing
// else — a child reads the game or the board, never the page.
interface IRoot {
game: Game
board: Board
}
// ---------------------------------------------------------------------------
// Game — the position, the engine loop, the status line
// ---------------------------------------------------------------------------
class Game extends Component {
// Bound by the strength dropdown; see the setter below.
#skill: SkillName = 'medium'
// The one nullable reference to the engine on this page. Every entry point
// below resolves it once and hands the non-null `Engine` down, so the "is the
// net up" question is asked at the door and nowhere after it.
#engine?: Engine
pos = newPosition(BOARD_SIZE, KOMI)
lastMove?: number
// What the readouts under the board report. One field replaces the pair of
// DOM writes the old showEval() made, so the bar and the score line can't
// disagree with each other or go stale.
shown?: Evaluation
// What the board may paint. Cleared independently of `shown`, because your
// own move makes the policy heat wrong immediately while the numbers under
// the board are still the last true reading.
policy?: Float32Array
ownership?: Float32Array
status = 'starting…'
// `waiting` shimmers the line. Reserve it for states where the page owes you
// something and can't say when: everything else is a result, and shimmering a
// result reads as though it were still settling.
waiting = true
// Until the engine is up there is nothing on the board to read, so the status
// line reads on the board rather than under it, where the eye already is.
loading = true
busy = false
#stack: Position[] = []
// Two states. 'auto' is the engine driving both colours — used as an attract
// loop on load, and again for Swap, where it takes over your side from the
// current position. `autoGen` is the cancellation token: the loop re-checks it
// after every await and bails if it has been superseded.
#mode: 'auto' | 'play' = 'auto'
#autoGen = 0
#gameOver = false
// Which colour you hold. Swap trades it with KataGo mid-game, so nothing may
// assume the human is Black.
#humanColor: Color = BLACK
// Record-keeping for the SGF header: the result once the game has an outcome,
// and whether a Swap means neither player held one colour throughout.
#gameResult = ''
#swapped = false
#started = false
get #board() { return (this.ctx.root as unknown as IRoot).board }
// ---- what the views read ----
// Whether the engine is up. Gates the one control that is live before a game
// exists, and stands behind `idle`.
get ready(): boolean { return this.#engine !== undefined }
// The controls are live only between the moves of a human game — not while
// the attract loop is running and not after the game has been scored.
get idle(): boolean { return this.ready && this.#mode === 'play' && !this.#gameOver }
get plies(): number { return this.pos.history.length }
// Undo goes two plies back, so it needs two to go back to.
get canUndo(): boolean { return this.#stack.length >= 2 }
scoreLine(): string {
const e = this.shown
if (!e) return ''
const lead = e.blackLead
return `black ${(e.blackWinrate * 100).toFixed(1)}% · ` +
`${lead >= 0 ? 'B' : 'W'}+${Math.abs(lead).toFixed(1)} · ${e.ms.toFixed(1)} ms`
}
// A setter, so the dropdown can bind to it directly and the side effect still
// happens: changing strength mid-game says so, rather than silently altering
// who you are playing.
get skill(): SkillName { return this.#skill }
set skill(v: SkillName) {
this.#skill = v
if (this.#mode === 'play' && !this.#gameOver) this.#setStatus(`Strength: ${v}. Your move.`)
}
// ---- state ----
#setStatus(s: string, waiting = false): void {
this.status = s
this.waiting = waiting
this.update()
}
// Moves are carried as policy indices throughout: 0..AREA-1 are points, AREA
// is a pass. Only here does that turn back into a board position.
#commitMove(move: number): void {
this.#stack.push(this.pos)
this.pos = playAssumeLegal(this.pos, move === AREA ? PASS : move)
this.lastMove = move === AREA ? undefined : move
this.update()
}
#resetGame(): void {
this.pos = newPosition(BOARD_SIZE, KOMI)
this.#stack = []
this.lastMove = undefined
this.#gameResult = ''
this.#swapped = false
}
// Adopt an evaluation as what the overlays and the score line are showing.
#applyEval(e: Evaluation | null): void {
this.policy = e?.policy
this.ownership = e?.ownership
if (e) this.shown = e
this.update()
}
async #refresh(engine: Engine): Promise<void> {
this.#applyEval(await engine.evaluate(this.pos))
}
// ---- playing ----
async #reply(engine: Engine): Promise<void> {
const c = await engine.chooseMove(this.pos, SKILLS[this.#skill])
if (!c) return
const engineWin = this.#humanColor === BLACK ? 1 - c.e.blackWinrate : c.e.blackWinrate
if (engineWin < RESIGN_WINRATE && this.pos.history.length >= RESIGN_MIN_PLY) {
this.#gameResult = `${this.#humanColor === BLACK ? 'B' : 'W'}+R`
await this.#finishGame(engine, 'KataGo resigns. You win.', false)
return
}
this.#commitMove(c.move)
if (endsGame(this.pos)) { await this.#finishGame(engine, 'Both players passed.', true); return }
// The move is already ringed on the board and its cost is already in the
// score line, so naming it again is noise. Two exceptions: a pass leaves no
// mark, so it has to be said; and search time is not the score line's `ms`,
// which times one evaluation rather than the whole search.
const cost = c.visits ? ` · ${c.visits} visits in ${c.ms.toFixed(0)} ms` : ''
this.#setStatus(c.move === AREA ? `KataGo passes. Your move.${cost}` : `Your move.${cost}`)
await this.#refresh(engine)
}
async #finishGame(engine: Engine, headline: string, withScore: boolean): Promise<void> {
this.#autoGen++ // stop any auto loop still in flight
this.#mode = 'play'
this.#gameOver = true
const e = await engine.evaluate(this.pos)
if (e) {
this.shown = e
this.policy = undefined
this.ownership = e.ownership
this.#board.overlay = 'ownership' // the shading IS the explanation of the score
}
if (withScore && e) this.#gameResult = scoreFromOwnership(e.ownership)
this.#setStatus(withScore && e
? `${headline} · ${scoreFromOwnership(e.ownership)} by the net's count.`
: headline)
}
// KataGo against itself, restarting forever — the attract loop, so the page
// isn't a dead board before you touch it. Every await is followed by a
// generation check so a click mid-move never lands after you've taken over.
async #runAttract(engine: Engine): Promise<void> {
const gen = ++this.#autoGen
this.#mode = 'auto'
this.#gameOver = false
this.#resetGame()
// Set once, not per move. The board and the score line already show what is
// happening; the only thing this line has to say is how to get in.
this.#setStatus(ATTRACT_STATUS)
let ply = 0
while (this.#mode === 'auto' && gen === this.#autoGen) {
// Two consecutive passes ends the game; Position tracks history, not a
// pass counter, so read it off the tail.
if (endsGame(this.pos) || ply >= 400) {
this.#setStatus('Self-play finished. Starting again…')
await sleep(2500)
if (gen !== this.#autoGen) return
this.#resetGame()
ply = 0
this.#setStatus(ATTRACT_STATUS)
continue
}
const t0 = performance.now()
let e: Evaluation | null
let move: number
if (ply < DEMO_OPENING_PLIES) {
e = await engine.evaluate(this.pos)
if (!e || gen !== this.#autoGen) return
move = sampleFrom(e.policy, DEMO_TEMP)
} else {
const c = await engine.chooseMove(this.pos, SKILLS[this.#skill])
if (!c || gen !== this.#autoGen) return
e = c.e
move = c.move
}
this.#applyEval(e)
this.#commitMove(move)
ply++
// Pace it. An unsearched move is ~6 ms, so unpaced it plays a whole game
// in a few seconds — far too fast to watch, and the sleep yields the
// thread for input. Searching settings already spend longer than the
// pacing, so only the remainder is waited out.
const rest = DEMO_MOVE_MS - (performance.now() - t0)
if (rest > 0) await sleep(rest)
if (gen !== this.#autoGen) return
}
}
#stopAuto(): void {
this.#autoGen++
this.#mode = 'play'
}
// ---- what the controls call ----
async newGame(): Promise<void> {
const engine = this.#engine
if (this.busy || !engine) return
this.#stopAuto()
this.busy = true
this.#gameOver = false
this.#humanColor = BLACK
this.#resetGame()
this.#setStatus('Your move. You are Black.')
await this.#refresh(engine)
this.busy = false
this.update()
}
async pass(): Promise<void> {
const engine = this.#engine
if (this.busy || !this.idle || !engine || this.pos.toPlay !== this.#humanColor) return
this.busy = true
this.#commitMove(AREA)
// Your pass may itself have ended the game, before KataGo is asked anything.
if (endsGame(this.pos)) { await this.#finishGame(engine, 'Both players passed.', true); this.busy = false; return }
this.#setStatus('You passed. KataGo is thinking…', true)
await this.#reply(engine)
this.busy = false
this.update()
}
async undo(): Promise<void> {
const engine = this.#engine
if (this.busy || !this.idle || !engine || !this.canUndo) return
this.busy = true
this.#gameOver = false
// Two plies back, so it stays your turn.
this.#stack.pop()
this.pos = this.#stack.pop() ?? newPosition(BOARD_SIZE, KOMI)
this.lastMove = undefined
this.#setStatus('Took back a move.')
await this.#refresh(engine)
this.busy = false
this.update()
}
// Trade colours with KataGo. It answers immediately if the swap has left it on
// move, which is the usual case — you swap instead of playing, so the move you
// were about to make becomes its move.
async swap(): Promise<void> {
const engine = this.#engine
if (this.busy || !this.idle || !engine) return
this.#humanColor = -this.#humanColor
this.#swapped = true
this.#setStatus(`Swapped. You are ${this.#humanColor === BLACK ? 'Black' : 'White'} now.`)
if (this.pos.toPlay !== this.#humanColor) {
this.busy = true
await this.#reply(engine)
this.busy = false
this.update()
} else {
await this.#refresh(engine)
}
}
sgfText(): string {
return toSgf({
pos: this.pos, mode: this.#mode, humanColor: this.#humanColor,
swapped: this.#swapped, result: this.#gameResult,
})
}
saveSgf(): void {
if (this.plies > 0) downloadSgf(this.sgfText(), this.plies)
}
async playPoint(row: number, col: number): Promise<void> {
const engine = this.#engine
if (this.busy || !engine || this.#gameOver) return
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return
// Clicking during the attract loop starts a fresh game and becomes your
// first move: inheriting a board you didn't choose is worse than starting
// over. Swap is the deliberate version of that, and has its own button.
if (this.#mode === 'auto') {
this.#stopAuto()
this.#humanColor = BLACK
this.#resetGame()
} else if (this.pos.toPlay !== this.#humanColor) return
const loc = row * BOARD_SIZE + col
if (!isLegal(this.pos, loc)) {
this.#setStatus('Illegal there: occupied, ko, or self-capture.')
return
}
this.busy = true
this.#commitMove(loc)
this.policy = undefined
this.#setStatus('KataGo is thinking…', true)
await this.#reply(engine)
this.busy = false
this.update()
}
// A raw pass by whoever is to move, with no engine reply: state with no
// control behind it. The only way to reach the two-passes-end-the-game path,
// since the net won't volunteer a pass this early.
forcePass(): void { this.#commitMove(AREA) }
// Everything a probe would otherwise have to scrape out of the DOM, reported
// from the state itself.
report() {
return {
status: this.status,
score: this.scoreLine(),
toPlay: this.pos.toPlay === BLACK ? 'B' : 'W',
human: this.#humanColor === BLACK ? 'B' : 'W',
ply: this.plies,
stack: this.#stack.length,
stones: this.pos.board.reduce((n, c) => n + (c !== EMPTY ? 1 : 0), 0),
mode: this.#mode,
gameOver: this.#gameOver,
}
}
// ---- startup ----
// Driven from the board's onMounted rather than onAttached, because the board
// existing is the condition the load waits behind. A remount (the About tab
// and back) must not start it a second time.
async start(): Promise<void> {
if (this.#started) return
this.#started = true
try {
await this.#boot()
} catch (e: unknown) {
this.#setStatus(`error: ${(e as { message?: string })?.message ?? String(e)}`)
}
}
async #boot(): Promise<void> {
// A frame before anything else. The board is already mounted and drawn by
// the time this runs, and this is where it gets painted — everything below
// holds the main thread, since compiling WGSL is synchronous, so a board
// that has not reached the screen by here would not appear until the engine
// is up.
await nextFrame()
if (!isWebGPUAvailable()) {
this.#setStatus('WebGPU is unavailable in this browser, and this bulb needs it.')
return
}
this.#setStatus('Compiling the graph and uploading the weights…', true)
const t0 = performance.now()
const engine = this.#engine = await Engine.load('assets', BOARD_SIZE)
// The board is live from here on and says more than any message can.
this.loading = false
tb.log(`ready in ${((performance.now() - t0) / 1000).toFixed(1)}s`)
this.update()
// Off to one side, so the batched pipeline's compile lands inside the
// attract loop rather than on the move after you change the strength.
void engine.warm()
await this.#runAttract(engine)
}
}
// ---------------------------------------------------------------------------
// Board — the canvas
// ---------------------------------------------------------------------------
class Board extends Component {
// Bound by the overlay dropdown. It lives here rather than on the game
// because it changes what is painted and nothing about what is played.
overlay: OverlayKind = 'ownership'
// The canvas is the one element any of this holds onto. It is painted rather
// than rendered, so it needs a context, a box to hit-test against, and a
// resize signal — none of which a view can express. Everything else about the
// layout is the stylesheet's, including the board's size.
#canvasEl?: HTMLCanvasElement
#ctx2d?: CanvasRenderingContext2D
// Board geometry in CSS pixels, re-derived whenever the canvas box changes.
#boardPx = 0
#cell = 0
#pad = 0
#lastLayoutWidth = -1
get #game() { return (this.ctx.root as unknown as IRoot).game }
view() {
const game = this.#game
return div({ class: 'board-wrap' },
canvas({
// Keyed so the diff can only ever patch this element. A recreated
// canvas is a blank backing store, and the board would vanish.
key: 'board',
onClick: (e: Event) => this.#click(e as MouseEvent),
onMounted: el => {
const c = el as HTMLCanvasElement
this.#canvasEl = c
this.#ctx2d = c.getContext('2d') ?? undefined
const ro = new ResizeObserver(() => { if (this.#layout()) this.draw() })
ro.observe(c)
this.#layout()
this.draw()
void game.start()
return () => {
ro.disconnect()
this.#canvasEl = undefined
this.#ctx2d = undefined
// A remount is a fresh element with a fresh backing store, so the
// cached width must not suppress the rebuild.
this.#lastLayoutWidth = -1
}
},
}),
game.loading
? div({ id: 'loading' },
p({ class: 'board-note' },
span({ id: 'loading-text', class: game.waiting ? 'waiting' : '' }, game.status)))
: null,
)
}
// Drawing happens in CSS pixels; the backing store carries the device pixel
// ratio, which also stops the whole board being a 1x bitmap upscaled on a
// retina display. The size it reads is whatever the stylesheet settled on.
#layout(): boolean {
const canvasEl = this.#canvasEl, ctx2d = this.#ctx2d
if (!canvasEl || !ctx2d) return false
// clientWidth, not the bounding rect: the canvas is border-box, so the rect
// is 2px wider than the surface actually drawn into.
const raw = canvasEl.clientWidth
if (raw < 1) return false
const width = Math.max(220, Math.round(raw))
if (width === this.#lastLayoutWidth) return false
this.#lastLayoutWidth = width
const dpr = Math.min(2, window.devicePixelRatio || 1)
// Assigning width resets the context transform, so the scale goes back after.
canvasEl.width = canvasEl.height = Math.round(width * dpr)
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0)
this.#boardPx = width
this.#cell = width / (BOARD_SIZE - 1 + 2 * PAD_RATIO)
this.#pad = this.#cell * PAD_RATIO
return true
}
#px(i: number): number { return this.#pad + i * this.#cell }
draw(): void {
const ctx2d = this.#ctx2d
if (!ctx2d || this.#cell <= 0) return
const game = this.#game
const cell = this.#cell
const css = getComputedStyle(document.documentElement)
const v = (n: string) => css.getPropertyValue(n).trim()
ctx2d.fillStyle = v('--board')
ctx2d.fillRect(0, 0, this.#boardPx, this.#boardPx)
// Ownership shading sits under the grid so it reads as territory, not marks.
if (this.overlay === 'ownership' && game.ownership) {
for (let i = 0; i < AREA; i++) {
const o = game.ownership[i]!
if (Math.abs(o) < 0.06) continue
ctx2d.fillStyle = o > 0
? `rgba(20,20,20,${Math.abs(o) * 0.42})`
: `rgba(250,248,242,${Math.abs(o) * 0.52})`
const x = this.#px(i % BOARD_SIZE), y = this.#px(Math.floor(i / BOARD_SIZE))
ctx2d.fillRect(x - cell / 2, y - cell / 2, cell, cell)
}
}
ctx2d.strokeStyle = v('--line')
ctx2d.lineWidth = 1
for (let i = 0; i < BOARD_SIZE; i++) {
ctx2d.beginPath(); ctx2d.moveTo(this.#px(i), this.#px(0)); ctx2d.lineTo(this.#px(i), this.#px(BOARD_SIZE - 1)); ctx2d.stroke()
ctx2d.beginPath(); ctx2d.moveTo(this.#px(0), this.#px(i)); ctx2d.lineTo(this.#px(BOARD_SIZE - 1), this.#px(i)); ctx2d.stroke()
}
ctx2d.fillStyle = v('--line')
for (const r of HOSHI) for (const c of HOSHI) {
ctx2d.beginPath(); ctx2d.arc(this.#px(c), this.#px(r), Math.max(2, cell * 0.085), 0, Math.PI * 2); ctx2d.fill()
}
if (this.overlay === 'policy' && game.policy) {
for (let i = 0; i < AREA; i++) {
const p = game.policy[i]!
if (p < 0.004) continue
ctx2d.fillStyle = v('--heat')
ctx2d.globalAlpha = Math.min(0.8, 0.12 + p * 2)
ctx2d.beginPath()
ctx2d.arc(this.#px(i % BOARD_SIZE), this.#px(Math.floor(i / BOARD_SIZE)), cell * 0.34, 0, Math.PI * 2)
ctx2d.fill()
ctx2d.globalAlpha = 1
}
}
for (let i = 0; i < AREA; i++) {
const c = game.pos.board[i]!
if (c === EMPTY) continue
const x = this.#px(i % BOARD_SIZE), y = this.#px(Math.floor(i / BOARD_SIZE))
ctx2d.beginPath(); ctx2d.arc(x, y, cell * 0.46, 0, Math.PI * 2)
ctx2d.fillStyle = c === BLACK ? '#151515' : '#f6f4ee'
ctx2d.fill()
ctx2d.strokeStyle = c === BLACK ? '#000' : '#b5b0a4'
ctx2d.stroke()
if (i === game.lastMove) {
ctx2d.beginPath(); ctx2d.arc(x, y, cell * 0.15, 0, Math.PI * 2)
ctx2d.strokeStyle = c === BLACK ? '#f6f4ee' : '#151515'
ctx2d.lineWidth = 2; ctx2d.stroke(); ctx2d.lineWidth = 1
}
}
}
// Hit-testing runs in CSS pixels, the same space the board is drawn in.
#click(ev: MouseEvent): void {
const canvasEl = this.#canvasEl
if (!canvasEl) return
const rect = canvasEl.getBoundingClientRect()
void this.#game.playPoint(
Math.round((ev.clientY - rect.top - this.#pad) / this.#cell),
Math.round((ev.clientX - rect.left - this.#pad) / this.#cell),
)
}
// A board click has no named control for `typebulb send` to aim at, so the
// probe synthesises the event the canvas handler actually reads. The setup is
// faked; the trigger stays real.
clickPoint(row: number, col: number): void {
const canvasEl = this.#canvasEl
if (!canvasEl) return
const rect = canvasEl.getBoundingClientRect()
canvasEl.dispatchEvent(new MouseEvent('click', {
clientX: rect.left + this.#px(col), clientY: rect.top + this.#px(row),
}))
}
}
// ---------------------------------------------------------------------------
// Controls — the row above the board
// ---------------------------------------------------------------------------
class Controls extends Component {
get #root() { return this.ctx.root as unknown as IRoot }
view() {
const game = this.#root.game
const idle = game.idle
// Two groups, not seven controls: the row's spacing is then one decision
// about two boxes, taken entirely in the stylesheet.
return div({ class: 'controls' },
div({ class: 'btn-group' },
button({ class: 'primary', disabled: !game.ready, onClick: () => void game.newGame() }, 'New Game'),
button({ disabled: !idle, onClick: () => void game.pass() }, 'Pass'),
button({ disabled: !idle || !game.canUndo, onClick: () => void game.undo() }, 'Undo'),
button({ disabled: !idle, onClick: () => void game.swap() }, 'Swap'),
// Deliberately not gated on `idle`: a self-play game and a finished
// game are both worth saving. The only thing that makes the record
// meaningless is having no moves in it.
button({ disabled: game.plies === 0, onClick: () => game.saveSgf() }, 'Download SGF'),
),
div({ class: 'selects' },
this.#picker('strength', game, () => game.skill, SKILL_NAMES),
this.#picker('overlay', this.#root.board, () => this.#root.board.overlay, OVERLAY_NAMES),
),
)
}
// A caption over a bound dropdown. The label wraps the select, which is what
// names it — for a screen reader, and for `tb:set combobox "strength" = hard`
// alike. Binding writes straight back onto the owning component, so there is
// no local copy of either setting and no handler to keep in step.
#picker<T extends string>(caption: string, target: Component, prop: () => T, options: readonly T[]) {
return label({ class: 'sel' }, caption,
inputSelect({ target, prop, options: options.map(o => ({ value: o, label: o })) }),
)
}
}
// ---------------------------------------------------------------------------
// Root — the page
// ---------------------------------------------------------------------------
class Root extends Component implements IRoot {
game = new Game()
board = new Board()
controls = new Controls()
// Two tabs: the game, and everything there is to say about it. The prose was
// pushing the board below the fold.
tab: 'play' | 'about' = 'play'
// The canvas is the one part of the page the framework doesn't render: it is
// painted, not diffed. `onUpdated` here catches every update in the tree — a
// move, a dropdown, the status line — so the repaint rides all of them and
// the board simply can't fall out of step with the state.
onUpdated(): void {
this.board.draw()
}
view() {
return div({ class: 'page' },
h1('KataGo'),
p({ class: 'subtitle' },
'A real KataGo network (b6c96) playing full-size Go in the browser. ',
strong('1.16 MB'), ' of weights, compiled to WGSL by tensorgrad.',
),
div({ class: 'tabs' }, this.#tabBtn('play', 'Play'), this.#tabBtn('about', 'About')),
this.tab === 'play' ? this.#playView() : this.#aboutView(),
)
}
#tabBtn(tab: 'play' | 'about', label: string) {
return button({
class: 'tab',
ariaSelected: this.tab === tab,
onClick: () => { if (this.tab !== tab) { this.tab = tab; this.update() } },
}, label)
}
#playView() {
const game = this.game
return section({ id: 'tab-play', class: game.loading ? 'loading' : '' },
this.controls.view(),
this.board.view(),
div({ class: 'bar-track' },
div({ id: 'bar', style: { width: `${((game.shown?.blackWinrate ?? 0.5) * 100).toFixed(1)}%` } })),
div({ id: 'score' }, game.scoreLine()),
// Hidden rather than emptied while the note is on the board, so its
// disappearance moves nothing.
div({ id: 'status', class: game.waiting ? 'waiting' : '' }, game.status),
)
}
#aboutView() {
return section({ id: 'tab-about' },
p({ class: 'note' },
strong('Rules.'), ' Chinese: area scoring, komi 7.5, simple ko (no superko), suicide ',
'illegal, no handicap. The input encoder is written for exactly those, so they are ',
'fixed here rather than a setting. Counting is the net’s own, off the ownership ',
'head, so there is no dead-stone negotiation and a game stopped early is scored on an ',
'unsettled board.',
),
p({ class: 'note' },
'It opens playing itself; click the board to take over as Black. No ONNX Runtime and ',
'no WASM blob.',
),
p({ class: 'note' },
'The ownership overlay is the net’s own territory estimate, an auxiliary head that ',
'comes free with every evaluation. The whole port was verified against the KataGo C++ ',
'engine on 24 reference positions, then quantised to INT8 for download.',
),
p({ class: 'note' },
strong('Pass'), ' twice and the game is scored: the ownership head decides who owns ',
'each point, which also settles which stones are dead, so the board shades into its ',
'own result. ', strong('Swap'), ' trades colours with KataGo: it plays the move you ',
'were about to make, and you carry on with the other side. It resigns when its winrate ',
'collapses, rather than playing a lost game out. ', strong('Download SGF'), ' saves the ',
'game record at any point, with the rules and the result in its header.',
),
p({ class: 'note' },
strong('Strength'), ' works two ways, because search only ever makes the net stronger, ',
'and its floor (', em('medium'), ') is the raw policy: the whole network, playing its ',
'single best guess. ', em('hard'), ' and ', em('hardest'), ' add 400 and 1200 visits of ',
'tree search. ', em('easy'), ' and ', em('easiest'), ' instead price the net’s own ',
'shortlist and take a move that gives away roughly 3 or 8 points, so they lose ',
'coherently rather than blundering at random.',
),
)
}
}
const root = new Root()
new App({ root, id: 'app' })
// Probe hook for `typebulb send`. Dev-only, and deliberately narrow: it holds
// only what the built-in verbs cannot reach. Every button and both dropdowns
// are named controls, so `tb:click button "Pass"` and `tb:set combobox
// "strength" = hard` already drive them — and a disabled target is an error
// there rather than a silent no-op, which is the whole point of using them.
tb.onMessage(async (msg: unknown) => {
const [cmd, a, b] = String(msg).trim().split(/\s+/)
if (cmd === 'play') {
// A point on the canvas has no role or name, so `tb:click` can't aim at it.
root.board.clickPoint(Number(a), Number(b))
} else if (cmd === 'forcepass') {
// A move no control offers — the only way to reach the two-passes ending.
root.game.forcePass()
} else if (cmd === 'sgf') {
// The record is a string with no home in the DOM, so a snapshot cannot read
// it, and the button only hands it to the browser's downloader.
return root.game.sgfText()
}
// Any other message just settles and reports. The built-in verbs reply with
// the immediate frame, which is too early for a searching move.
for (let i = 0; i < 600 && root.game.busy; i++) await sleep(50)
return root.game.report()
})
```
**styles.css**
```css
:root {
color-scheme: light;
--bg: #fff; --fg: #1a1a1a; --muted: #6b6b6b; --border: #ddd;
--board: #e3c489; --line: #6f5a37; --heat: #1d7fd6;
--accent: #2d6a4f; --accent-fg: #fff; --btn-bg: #fff; --btn-border: #bdbdbd;
--bar-bg: #e6e6e6; --bar-fg: #2b2b2b;
--shim-base: #b0b0b0; --shim-hot: #101010;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #1b1b1b; --fg: #ededed; --muted: #9d9d9d; --border: #333;
--board: #b9955e; --line: #46381f; --heat: #5eb0ff;
--accent: #52b788; --accent-fg: #0d1f16; --btn-bg: #2a2a2a; --btn-border: #454545;
--bar-bg: #333; --bar-fg: #ededed;
--shim-base: #5a5a5a; --shim-hot: #ffffff;
}
body {
margin: 0; background: var(--bg); color: var(--fg);
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
}
/* The page is exactly the viewport, and everything inside it is a fixed-height
row except the board, which takes what is left. The vertical space is padding
rather than margin: a margin sits outside the box, so whether it counts
toward the document's height depends on the host, and it can't be part of a
height that has to add up exactly. The width is wide enough that the five
buttons and both dropdowns share one line — they come to roughly 600px
together, and 660 left no room. */
.page {
max-width: 780px; margin: 0 auto; padding: 2rem 1rem;
box-sizing: border-box; height: 100dvh;
display: flex; flex-direction: column;
}
#tab-play { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
/* The prose is shorter than the viewport on most screens, so this scrollbar is
a fallback for a short window rather than the normal state. */
#tab-about { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* Zeroed because the page's top space is its padding now: a default heading
margin no longer collapses into it, it stacks on top of it. */
h1 { font-size: 1.35rem; margin-top: 0; margin-bottom: 0.3rem; }
.subtitle { color: var(--muted); margin-top: 0; margin-bottom: 1.1rem; line-height: 1.55; }
.tabs {
display: flex; gap: 0.25rem;
border-bottom: 1px solid var(--border); margin-bottom: 1rem;
}
.tab {
padding: 0.45rem 0.9rem; font-size: 0.9rem; cursor: pointer;
background: none; border: none; border-radius: 0; color: var(--muted);
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tab[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
.controls {
display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap;
margin-bottom: 0.9rem;
}
/* Auto margins on both sides of each group, rather than a justify-content that
would have to change on wrap. Sharing a line, the free space splits four ways
and the two groups sit evenly inset; wrapped, each line holds one group whose
own two margins centre it. That is both behaviours at every width, with no
breakpoint to guess — `space-between` gets the first but pins a wrapped line
to the start, which is why this used to be measured in script. */
.btn-group, .selects { margin-inline: auto; }
.btn-group { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
/* The dropdowns carry their captions above them, so their column is taller than
a button. Bottom-aligning the row plus a shared control height is what keeps
the select boxes on the same line as the buttons rather than sagging below. */
.controls button, .controls select { height: 2.15rem; }
button {
padding: 0 0.9rem; font-size: 0.9rem; cursor: pointer;
background: var(--btn-bg); color: var(--fg);
border: 1px solid var(--btn-border); border-radius: 4px;
}
button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button:disabled { opacity: 0.45; cursor: default; }
.sel {
display: flex; flex-direction: column; gap: 0.25rem;
font-size: 0.72rem; color: var(--muted); letter-spacing: 0.02em;
}
/* One flex item holding both dropdowns, so they ride the button row when it
fits and wrap together onto their own line when it doesn't — rather than the
overlay dropping alone and leaving strength orphaned beside the buttons. */
.selects { display: flex; gap: 0.75rem; align-items: flex-end; }
select {
font-family: inherit; font-size: 0.85rem;
padding: 0 0.4rem; background: var(--btn-bg); color: var(--fg);
border: 1px solid var(--btn-border); border-radius: 4px;
}
/* The board takes whichever runs out first: the width of its column, or the
height the rest of the page leaves. That is what `min(100cqw, 100cqh)` says,
against a board-wrap that is sized by the flex column and declared a size
container — so the two candidate sizes are both facts the layout already
knows, and neither has to be measured or re-applied. It is also monotonic in
the window by construction: no breakpoint to cross, so a middling window can
never end up with a bigger board than a large one. The floor is the one case
where the page is allowed to scroll instead: below it a 19x19 board stops
being worth playing on.
border-box so the width covers the 1px border too — the height it is racing
against is a border box, and 2px is all it takes to bring back a scrollbar. */
.board-wrap {
flex: 1 1 auto; min-height: 0; container-type: size;
display: flex; align-items: center; justify-content: center;
}
canvas {
display: block; aspect-ratio: 1 / 1;
width: max(320px, min(100cqw, 100cqh));
box-sizing: border-box; cursor: pointer;
border: 1px solid var(--border); border-radius: 6px;
/* The same board, in CSS, so it is on screen before any script has run —
nothing about wood and 19 lines needs a WGSL compiler to arrive from a CDN
first, which is what a static `import from 'tensorgrad'` otherwise makes it
wait for. draw() paints over all of it opaquely on its first pass.
The two constants are PAD_RATIO's, in percentages of the board:
span = 18 + 2 * 22/30 = 19.4667 cells -> cell = 100/span = 5.137%
pad = 22/30 * cell -> 3.767%
A tiled background's percentage offset is measured against the box LESS one
tile, so the offset that lands the first line on the pad is
3.767 / (1 - 0.05137) = 3.9711%, not 3.767%. */
background-color: var(--board);
background-image:
linear-gradient(to right, var(--line) 0 1px, transparent 1px),
linear-gradient(to bottom, var(--line) 0 1px, transparent 1px);
background-size: 5.137% 5.137%;
background-position: 3.9711% 3.9711%;
}
/* The overlay covers the wrapper rather than the board, which comes to the same
place: the board is centred in it, so the note is centred on the board. A
size container is already a containing block, so it needs no `position`. */
#loading {
position: absolute; inset: 0; display: grid; place-items: center;
pointer-events: none; padding: 0 1rem;
}
/* An opaque card, not bare text: the board is a mid-tone wood in both themes,
and the shimmer's stops are chosen against the page background. On the wood
the bright end of the sweep all but disappears. */
.board-note {
margin: 0; padding: 0.5rem 0.85rem; border-radius: 8px;
background: var(--bg); border: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85rem;
color: var(--muted); line-height: 1.5; text-align: center; max-width: 34ch;
}
/* Hidden, not emptied: the line keeps its box, so the layout is the same
before and after the overlay goes, and the board never resizes underneath it. */
#tab-play.loading #status { visibility: hidden; }
.bar-track {
height: 6px; background: var(--bar-bg); border-radius: 3px;
overflow: hidden; margin-top: 0.9rem;
}
#bar { height: 100%; width: 50%; background: var(--bar-fg); transition: width 0.25s ease; }
#score, #status {
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.82rem;
color: var(--muted); margin-top: 0.5rem; min-height: 1.3em;
}
/* A highlight band swept across the glyphs themselves, via background-clip, so
the waiting reads as the text working rather than as a spinner parked beside
it. Both stops are themed, so it stays legible in light and dark, and with
the animation suppressed it degrades to a static gradient rather than to
invisible text. */
/* Scoped to #status, not bare .waiting: `#score, #status` sets `color` at ID
specificity, which beats a class, so `color: transparent` silently loses and
the glyphs stay painted over the gradient. */
#status.waiting, #loading-text.waiting {
/* inline-block so the gradient box hugs the glyphs. As a full-width block the
band spends most of its sweep over empty space to the right of the text,
which is how a technically-correct shimmer ends up looking like nothing. */
display: inline-block;
background: linear-gradient(90deg,
var(--shim-base) 35%, var(--shim-hot) 50%, var(--shim-base) 65%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 1.5s linear infinite;
}
/* 100% -> 0% walks the highlight from the left edge to the right edge and no
further. Sweeping to a negative position parks it off-screen for half the
cycle, so the line sits dead between glints. */
@keyframes shimmer {
from { background-position: 100% 0; }
to { background-position: 0% 0; }
}
@media (prefers-reduced-motion: reduce) {
#status.waiting { animation: none; }
}
.note {
font-size: 0.95rem; color: var(--muted); line-height: 1.65;
margin-top: 0; margin-bottom: 1.1rem;
}
```
**index.html**
```html
<div id="app"></div>
```
**config.json**
```json
{
"description": "A real KataGo network (b6c96) playing full-size Go in the browser. 1.16 MB of weights, compiled to WGSL by tensorgrad.",
"dependencies": {
"domeleon": "^0.6.3",
"tensorgrad": "^0.4.3"
}
}
```