Skip to content

Instantly share code, notes, and snippets.

@kigiri
Last active April 26, 2025 23:23
Show Gist options
  • Save kigiri/7e21077c97dedb185bfba991df042675 to your computer and use it in GitHub Desktop.
Save kigiri/7e21077c97dedb185bfba991df042675 to your computer and use it in GitHub Desktop.
Tower defense vs Vampire Survivor
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>TD</title>
</head>
<body>
<style>
html {
background: #111;
}
body {
display: flex;
height: 100vh;
overflow: hidden;
justify-content: center;
align-items: center;
}
svg {
border-radius: 8px;
}
</style>
<script>
const toPos = (x, y) => {
x = x < 0 ? GRID_W - 1 + x : x
y = y < 0 ? GRID_H - 1 + y : y
const pos = y * GRID_W + x
if (pos < 0 || pos >= GRID_MAX) return -1
return Math.trunc(pos)
}
const toX = (pos) => (pos % GRID_W) | 0
const toY = (pos) => (pos / GRID_W) | 0
const GRID_H = 31
const GRID_W = 61
const GRID_MAX = GRID_H * GRID_W
const BASE = (GRID_MAX - 1) / 2
const BASE_X = toX(BASE)
const BASE_Y = toY(BASE)
const TOP_LEFT_SPAWNER = toPos(4, 4)
const TOP_RIGHT_SPAWNER = toPos(4, -4)
const BOTTOM_RIGHT_SPAWNER = toPos(-4, -4)
const BOTTOM_LEFT_SPAWNER = toPos(-4, 4)
const UNREACHABLE = -4
let WAVE = 1
let FRAME = -1
const spawners = [
TOP_LEFT_SPAWNER,
TOP_RIGHT_SPAWNER,
BOTTOM_RIGHT_SPAWNER,
BOTTOM_LEFT_SPAWNER,
]
const [
EMPTY,
OBSTACLE,
SPAWN,
] = Array(999).keys()
const state = new Uint16Array(GRID_MAX).fill(EMPTY)
const create = (tag) =>
document.createElementNS('http://www.w3.org/2000/svg', tag)
const totalNeighbor = (src, pos) =>
(~~src[pos - GRID_W - 1]) + (~~src[pos - GRID_W]) + (~~src[pos - GRID_W + 1])
+ (~~src[pos - 1]) + (~~src[pos + 1])
+ (~~src[pos + GRID_W - 1]) + (~~src[pos + GRID_W]) + (~~src[pos + GRID_W + 1])
const totalCloseNeighbor = (src, pos) =>
(~~src[pos - GRID_W])
+ (~~src[pos - 1]) + (~~src[pos + 1])
+ (~~src[pos + GRID_W])
const permutations = a =>
a.length < 2 ? [a] : a.reduce((r, e, i) => r.concat(
permutations([...a.slice(0, i), ...a.slice(i + 1)]).map(v => [e, ...v])
), [])
const nearest = new Function('pos, type', `
const r = Math.random() * 24
${permutations(['pos - GRID_W', 'pos + GRID_W', 'pos - 1', 'pos + 1'])
.map((checks, i) => `
if (r < ${i + 1}) {
${checks.map(check => `if (state[${check}] === type) return ${check}`).join('\n ')}
return -1
}`).join('\n')
}`)
const destroy = (enemy) => {
enemy.hp = 0
enemy.cx.value = -10
enemy.cy.value = -10
for (const { tower } of towerGrid[(~~enemy.y) * GRID_W + (~~enemy.x)]) {
tower.targets.delete(enemy)
}
}
// const nearest = (pos, type) => {
// if (state[pos - GRID_W] === type) return pos - GRID_W // top
// if (state[pos - 1] === type) return pos - 1 // right
// if (state[pos + GRID_W] === type) return pos + GRID_W // bottom
// if (state[pos + 1] === type) return pos + 1 // left
// }
const directionLookup = new Int16Array(GRID_MAX).fill(-1)
const computePathLookup = (() => {
const queue = new Uint16Array(GRID_MAX)
const tmp = new Int16Array(GRID_MAX).fill(-1)
const checkNeighbor = (index, x, offset) => {
const neighborIndex = index + offset
if (
// Basic bounds check
(neighborIndex < 0 || neighborIndex >= GRID_MAX) ||
// Moving right from last column
(offset === 1 && x === GRID_W - 1) ||
// Moving left from first column
(offset === -1 && x === 0) ||
// Check if visited
(tmp[neighborIndex] !== -1) ||
// Check if not free
(state[neighborIndex] !== EMPTY)
) return
tmp[neighborIndex] = index // Point neighbor back to current cell
queue[queueTail++] = neighborIndex // Enqueue neighbor
}
return () => {
tmp.fill(-1)
queue.fill(0)
// BFS loop
queueTail = 0
queueHead = -1
tmp[BASE] = BASE
queue[queueTail++] = BASE
while (++queueHead < queueTail) {
const index = queue[queueHead]
const x = index % GRID_W // Needed for left/right boundary checks
checkNeighbor(index, x, -GRID_W) // North
checkNeighbor(index, x, GRID_W) // South
checkNeighbor(index, x, 1) // East
checkNeighbor(index, x, -1) // West
}
// Ensure we have a path available from our spawns
for (const spawn of spawners) {
if (totalCloseNeighbor(tmp, spawn) === UNREACHABLE) return false
tmp[spawn] = nearest(spawn, EMPTY)
// TODO: hardcode check direction depending on
// where in the map we are.
}
directionLookup.set(tmp)
return true
}
})()
const towers = []
const towerGrid = Array(GRID_MAX).keys().map(() => []).toArray()
const addTowerToGrid = (tower) => {
towers.push(tower)
const towerRadius = create('circle')
const x = toX(tower.pos)
const y = toY(tower.pos)
const towerCenterX = (x + 0.5) * 36 // For drawing the visual circle
const towerCenterY = (y + 0.5) * 36 // For drawing the visual circle
towerRadius.setAttribute('fill', '#fff2')
towerRadius.setAttribute('r', tower.radius * 36)
towerRadius.setAttribute('cx', towerCenterX)
towerRadius.setAttribute('cy', towerCenterY)
svg.append(towerRadius)
const outerR = tower.radius + 0.5
const innerR = tower.radius - 0.5
const outerSq = outerR * outerR
const innerSq = innerR * innerR
const minX = Math.max(x - tower.radius, 0)
const maxX = Math.min(x + tower.radius, GRID_W - 1)
const minY = Math.max(y - tower.radius, 0)
const maxY = Math.min(y + tower.radius, GRID_H - 1)
for (let cx = minX; cx <= maxX; cx++) {
for (let cy = minY; cy <= maxY; cy++) {
const cellPos = toPos(cx, cy)
const elem = elems[cellPos]
if (!elem) continue
const diffX = cx - x
const diffY = cy - y
const distSq = diffX * diffX + diffY * diffY
distSq < outerSq && towerGrid[cellPos].push({
x: x + 0.5,
y: y + 0.5,
dist: tower.radius * tower.radius,
tower,
outer: distSq > innerSq,
})
}
}
}
const svg = create('svg')
const elemPos = new Map()
const elems = Array(GRID_MAX)
INIT_STATE: {
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
svg.setAttribute('viewBox', `0 0 ${GRID_W * 36} ${GRID_H * 36}`)
for (const i of elems.keys()) {
const x = toX(i)
const y = toY(i)
const elem = create('rect')
elem.setAttribute('x', x * 36 + 1)
elem.setAttribute('y', y * 36 + 1)
elem.setAttribute('width', 32)
elem.setAttribute('height', 32)
elem.setAttribute('fill', '#2224')
elem.setAttribute('rx', 2)
elem.setAttribute('ry', 2)
elem.setAttribute('id', `pos${i}`)
elemPos.set(elem, i)
svg.append(elem)
elems[i] = elem
}
for (const pos of spawners) {
const elem = elems[pos]
state[pos] = SPAWN
elem.setAttribute('fill', 'hotpink')
elem.setAttribute('rx', 8)
elem.setAttribute('ry', 8)
}
// Set the base
const base = elems[BASE]
state[BASE] = BASE
base.setAttribute('fill', '#BADA55')
base.setAttribute('rx', 8)
base.setAttribute('ry', 8)
// Distribute random obstacles
const OBSTACLE_DENSITY = 0.15 // 15%
const OBSTACLE_COUNT = Math.round(OBSTACLE_DENSITY * GRID_MAX)
let obstaclePlaced = 0
while (obstaclePlaced < OBSTACLE_COUNT) {
const pos = Math.round(Math.random() * GRID_MAX)
// count nearby empty blocks
if (state[pos] !== EMPTY) continue
const count = totalNeighbor(state, pos)
if (count > 0) continue
state[pos] = OBSTACLE
elems[pos].setAttribute('fill', '#444')
elems[pos].setAttribute('rx', 8)
elems[pos].setAttribute('ry', 8)
obstaclePlaced++
}
// Inital path compute
computePathLookup()
document.body.append(svg)
}
const MAX_ENEMIES = 8192
let enemies = []
ENEMY_TEST: {
// break ENEMY_TEST
const start = performance.now()
let last = start
const freeSlots = state.map((s, i) => s === EMPTY ? i : 0).filter(Boolean)
const makeEnemy = (i) => {
// const spawnPos = BOTTOM_RIGHT_SPAWNER
// const spawnPos = spawners[~~(Math.random()* spawners.length)]
const spawnPos = freeSlots[~~(Math.random()* freeSlots.length)]
const elem = create('circle')
elem.setAttribute('fill', '#F45')
elem.setAttribute('r', 6)
elem.setAttribute('stroke-opacity', '0.5')
svg.append(elem)
return {
dir: directionLookup[spawnPos],
s: Math.random() / 50 + 0.015,
x: toX(spawnPos) + 0.5,
y: toY(spawnPos) + 0.5,
hp: 0,
cx: elem.cx.baseVal,
cy: elem.cy.baseVal,
elem,
}
}
enemies = Array(MAX_ENEMIES).keys().map(makeEnemy).toArray()
const near = n => ~~(n * 100) / 100
const gameLoop = () => {
FRAME++
let i = -1
if (FRAME % 2048 === 0) {
WAVE++
NEXT_SPAWN: for (const spawn of spawners) {
let count = 0
for (const enemy of enemies) {
if (enemy.hp > 0) continue
if (++count > 10) continue NEXT_SPAWN
enemy.hp = 100
enemy.x = toX(spawn) + 0.5
enemy.y = toY(spawn) + 0.5
enemy.dir = directionLookup[spawn]
enemy.elem.setAttribute('fill', `oklch(0.77 0.1197 ${275})`)
}
}
}
if (FRAME % 300 === 0) {
for (const tower of towers) {
console.log(tower.targets)
}
}
while (++i < GRID_MAX) {
if (state[i] !== OBSTACLE) continue
elems[i]
nearest()
}
// Update enemies
i = -1
while (++i < MAX_ENEMIES) {
const enemy = enemies[i]
if (enemy.hp < 1) continue
const posX = ~~enemy.x
const posY = ~~enemy.y
const pos = posY * GRID_W + posX
// Hit the base !!
if (pos === BASE) {
destroy(enemy)
// TODO: damage the base
continue
}
// Check entering tower ranges
let inRange = false
for (const { x, y, outer, dist, tower } of towerGrid[pos]) {
if (outer) {
// check if in range, if not, continue
const diffX = enemy.x - x
const diffY = enemy.y - y
if ((diffX * diffX + diffY * diffY) > dist) {
tower.targets.delete(enemy)
continue
}
}
inRange = true
tower.targets.add(enemy)
}
if (inRange) {
enemy.elem.setAttribute('stroke', 'white')
} else {
enemy.elem.removeAttribute('stroke')
}
const dir = enemy.dir
const dirX = toX(dir)
const dirY = toY(dir)
const tY = dirY + 0.5
const tX = dirX + 0.5
const dx = tX - enemy.x
const dy = tY - enemy.y
if (Math.abs(dy) > 1e-6) {
const next = enemy.y + Math.sign(dy) * enemy.s
enemy.y = (dy > 1e-6 ? Math.min : Math.max)(next, tY)
enemy.x = tX
} else {
const next = enemy.x + Math.sign(dx) * enemy.s
enemy.x = (dx > 1e-6 ? Math.min : Math.max)(next, tX)
enemy.y = tY
}
if (enemy.y === tY && enemy.x === tX) {
enemy.dir = directionLookup[dir]
}
enemy.cx.value = enemy.x * 36 - 1
enemy.cy.value = enemy.y * 36 - 1
}
// Trigger towers attacks
for (const tower of towers) {
if (FRAME - tower.hit > tower.cooldown) {
tower.hit = FRAME
for (const enemy of tower.targets) {
enemy.hp -= Math.random() * 25 + 12.5
if (enemy.hp < 1) {
destroy(enemy)
} else {
enemy.elem.setAttribute('fill', `oklch(0.77 0.1197 ${275 * (enemy.hp / 100)})`)
}
// TODO: Animate projectile
break
}
}
}
requestAnimationFrame(gameLoop)
}
gameLoop()
}
// TODO:
// - spawn in waves from spawners
// - make basic towers
// - basic towers attack logic
// - enemies health points & death
// - base health points
// - add economy
// - add more enemies
// - add more towers
// - tune difficulty
// - pan & zoom (?)
ADD_OBSTACLE: {
addEventListener('click', e => {
const pos = elemPos.get(e.target)
pos && addTowerToGrid({
pos,
radius: 4,
targets: new Set(),
cooldown: 100,
hit: FRAME,
})
/*
if (!pos || state[pos] !== EMPTY) return
const rollbackValue = state[pos]
state[pos] = OBSTACLE
if (computePathLookup()) {
elems[pos].setAttribute('fill', '#444')
elems[pos].setAttribute('rx', 8)
elems[pos].setAttribute('ry', 8)
let i = -1
while (++i < MAX_ENEMIES) {
const enemy = enemies[i]
if (enemy.dir === pos) {
enemy.dir = nearest(pos, EMPTY)
// TODO: IDEAL behavior
// find the nearest free pos base of X / Y
// if very close to the center use the random one
// give a short speed boost while on the occupied block
}
}
} else {
state[pos] = rollbackValue
}
*/
})
}
DEBUG_DIRECTION: {
break DEBUG_DIRECTION
const getDirection = (pos) =>
(directionLookup[pos] === -1 && 'obstacle') ||
(directionLookup[pos] === pos - GRID_W && 'north') ||
(directionLookup[pos] === pos + GRID_W && 'south') ||
(directionLookup[pos] === pos + 1 && 'east') ||
(directionLookup[pos] === pos - 1 && 'west') || 'center'
const colors = {
center: '#fff',
north: '#0ff2',
south: '#f0f2',
east: '#ff02',
west: '#0f02',
obstacle: '#000'
}
const setColors = () => {
for (const pos of elems.keys()) {
const dir = getDirection(pos)
if (state[pos] !== EMPTY && state[pos] !== OBSTACLE) continue
elems[pos].setAttribute('fill', colors[dir])
}
}
requestAnimationFrame(function update() {
setColors()
setTimeout(() => {
requestAnimationFrame(update)
}, 50)
})
}
// Every 10th of a level, you get 1 action, actions can be used for:
// Action:
// Destroy a tower or obstacle
// Place a tower
// Heal base
// every 30 level choose:
// Effect:
// Your reloading effects always target the slowest cooldown
// Your base slowly regenerate HP (instantly regen to full health the base)
// +1 rebound (+3 towers have rebound enchant)
// +50% crit damages (+3 towers are crit enchanted)
// +25% XP (gain one free level instantly)
// -25% cooldown (+3 towers have shorter cooldown enchant)
// double splash effect range (+3 towers have splash effect)
// Increase damages taken by afflicted enemies (+3 mighty towers)
// Always crit on enemy afflicted with ice (+3 icy towers)
// Double slow duration (+3 Sticky towers)
// Double burn duration (+3 Fiery towers)
// Your execute effect start at 40% instead of 20% (+3 Deadly towers)
// Beam +50% damage on beams (+3 beams equipements)
// Sentry +1 rebound on sentries (+3 sentry equipements)
// Artillery (+3 artillery equipements)
// One more choice per levels (choose between an any of the epic options)
// 1 rerolls every 10 level (gain 3 rerolls now)
// Every levels choose:
// [Common] Bonuses:
// Damage bonus (3.0% [common] | 4.5% [rare] | 6% [epic])
// Crit chance bonus (5.0% [common] | 7.5% [rare] | 10% [epic])
// Cooldown Reduction bonus (2.5% [common] | 3.7% [rare] | 5% [epic])
// Rebound chances (5.0% [common] | 7.5% [rare] | 10% [epic])
// Splash chances (5.0% [common] | 7.5% [rare] | 10% [epic])
// [Uncommon] Stats:
// Increase base HP
// Reduce base damage taken
// Increase XP %
// Increase slow duration (must have at least one slow)
// Increase burn duration (must have at least one burn)
// Increase damage on frozen targets
// Increase slow %
// Increase burn % (dot effect)
// Increase execution damage (5.0% [common] | 7.5% [rare] | 10% [epic])
// [Rare] Improve a tower: (a tower can be improved up to 3 times using one of those)
// Tower Equipement
// - Beam: Short range, super fast fire rate (lazer beam), low damages
// - Sentry: Medium range, normal fire rate (Shot, fast projectile), medium damage
// - Artillery: Long range, slow fire rate, very high damages (target enemy with most hp)
// Towers Enchantements:
// [epic] Icy: Stacking Freeze effect (can not move for a very short time, and increase damage taken, duration depend of initial tower cooldown)
// [uncommon] Sticky: Stacking Slow effect (medium duration)
// [rare] Fiery: Stacking Burn effect (10% of damages done)
// Tower Buffs:
// Lucky: 50% crit
// Shiny: Reduce cooldown by 25%
// Bouncy: 50% chances of rebound
// Mighty: 30% damage increase
// Splashy: 50% chance of splash damages
// Bounty: triple XP rewared from it's kills
// Deadly: Deals 300% bonus damage to enemies below 20% health.
// [Epic] Triggers:
// Blazing speed: On burn, reload a near by tower
// Kinetic Relay: On slow, reload a near by tower
// Cryo-Charge: freeze, reload a near by tower
// Eruption: On burn damage an near by enemy
// Backlash: On slow, damage an near by enemy
// Ice Shards: On freeze damage an near by enemy
// Frostfire: On freeze -> burn
// Ignition: On slow -> burn
// Meltdown: On burn -> slow
// Permafrost: On freeze -> slow
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment