diff --git a/posts/2023-08-25_wasm-game-of-life-2.md b/posts/2023-08-25_wasm-game-of-life-2.md
new file mode 100644
index 0000000..7ef9f8b
--- /dev/null
+++ b/posts/2023-08-25_wasm-game-of-life-2.md
@@ -0,0 +1,30 @@
+---
+title: "Pure Wasm Life 2: Optimizing Webassembly and Canvas"
+subtitle: You know I couldn't leave it alone
+resources:
+- wasm-life-2/controller.js
+- wasm-life-2/game.wat
+---
+
+This post is part 2 of my Webassembly Game of Life series, read part 1
+[here](/wasm-game-of-life-1).
+
+[If you enable Javascript, you'll see a game board here]
+
+
+
+ Reset
+
+
+
+
diff --git a/scripts/wasm-life-2/controller.js b/scripts/wasm-life-2/controller.js
new file mode 100644
index 0000000..7c310c2
--- /dev/null
+++ b/scripts/wasm-life-2/controller.js
@@ -0,0 +1,146 @@
+let gameState = {
+ running: false,
+ pixelSize: 0,
+ lastReported: null,
+ frameTimes: [],
+ frames: 0,
+ width: 0,
+ height: 0,
+ canvas: null,
+ ctx: null,
+ gameExports: null
+}
+
+const initialMessage = "Click the board above to start simulation"
+
+export async function setup(params, wasmModule) {
+ const { canvas: canvasSelector } = params;
+ gameState.gameExports = wasmModule.exports
+
+ const canvas = gameState.canvas = document.querySelector(canvasSelector)
+ gameState.ctx = gameState.canvas.getContext("2d")
+ gameState.ctx.fillStyle = getComputedStyle(gameState.canvas).getPropertyValue('--foreground')
+
+ const pixelSize = gameState.pixelSize = parseInt(canvas.getAttribute('data-pixelsize') || '2')
+ gameState.width = Math.floor(parseInt(canvas.width) / pixelSize)
+ gameState.height = Math.floor(parseInt(canvas.height) / pixelSize)
+
+ initialize()
+ drawBoard()
+
+ const frameTimesElem = document.getElementById('frameTimes')
+ const resetButton = document.getElementById('reset')
+
+ frameTimesElem.innerText = initialMessage
+
+ gameState.canvas.addEventListener('click', () => {
+ if (!gameState.running) {
+ if (frameTimesElem.innerText === initialMessage)
+ frameTimesElem.innerText = 'Starting...'
+
+ gameState.running = true
+ frameLoop()
+ } else {
+ gameState.running = false;
+ gameState.frameTimes = []
+ gameState.lastReported = null
+ }
+ })
+
+ resetButton.addEventListener('click', () => {
+ gameState.running = false
+ gameState.frameTimes = []
+ gameState.lastReported = null
+ frameTimesElem.innerText = initialMessage
+ initialize()
+ drawBoard()
+ })
+}
+
+export async function onThemeChange() {
+ drawBoard()
+ gameState.ctx.fillStyle = getComputedStyle(gameState.canvas).getPropertyValue('--foreground')
+}
+
+export async function cleanup() {
+ gameState.running = false
+}
+
+function initialize() {
+ const { gameExports, width, height } = gameState
+
+ gameExports.initializeBoard(width, height)
+
+ for (let row = 0; row < height; row++) {
+ for (let column = 0; column < width; column++) {
+ const filled = Math.random() > .5;
+ gameExports.setValueAtPosition(row, column, filled ? 1 : 0)
+ }
+ }
+}
+
+function frameLoop() {
+ const { gameExports, running, frameTimes } = gameState
+
+ if (!gameState.lastReported)
+ gameState.lastReported = performance.now()
+
+ if (!running) return
+
+ const beforeTick = performance.now()
+ gameExports.tick()
+ const afterTick = performance.now()
+ drawBoard()
+ const afterDraw = performance.now()
+
+ // Push raw frame time
+ frameTimes.push(afterDraw - beforeTick)
+
+ if (frameTimes.length >= 10) {
+ const averageFrame = (frameTimes.reduce((a, b) => a + b) / frameTimes.length).toFixed(2);
+
+ const lastReported = gameState.lastReported
+ const current = gameState.lastReported = performance.now()
+ const tenFrameTime = (current - lastReported) / 1000
+ const fps = Math.floor(10 / tenFrameTime).toFixed(0)
+ gameState.frameTimes = []
+
+ const pagesUsed = gameExports.getPagesUsed()
+
+ document.getElementById('frameTimes').innerHTML =
+ `Frames per second: ${fps}
+ Milliseconds per frame: ${averageFrame}
+ Memory allocated: ${pagesUsed} pages (${pagesUsed * 64} KiB)`
+ }
+
+ const tickMS = (afterTick - beforeTick).toFixed(3).padStart(7, ' ')
+ const drawMS = (afterDraw - afterTick).toFixed(3).padStart(7, ' ')
+
+ console.log(`tick took ${tickMS}ms, draw took ${drawMS}ms`)
+
+ requestAnimationFrame(frameLoop)
+}
+
+function drawBoard() {
+ const { gameExports, width, height, pixelSize, ctx, canvas } = gameState
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+ ctx.beginPath()
+ for (let row = 0; row < height; row++) {
+ for (let column = 0; column < width; column++) {
+ const alive = gameExports.getValueAtPosition(row, column)
+
+ if (!alive) continue
+
+ const x = column * pixelSize
+ const y = row * pixelSize
+
+ ctx.moveTo(x, y)
+ ctx.lineTo(x + pixelSize, y)
+ ctx.lineTo(x + pixelSize, y + pixelSize)
+ ctx.lineTo(x, y + pixelSize)
+ ctx.lineTo(x, y)
+ }
+ }
+ ctx.fill()
+}
diff --git a/scripts/wasm-life-2/game.wat b/scripts/wasm-life-2/game.wat
new file mode 100644
index 0000000..1c895a8
--- /dev/null
+++ b/scripts/wasm-life-2/game.wat
@@ -0,0 +1,337 @@
+(module
+ (memory (export "shared_memory") 1)
+
+ (global $boardWidth (mut i32) (i32.const 0))
+ (global $boardHeight (mut i32) (i32.const 0))
+ (global $boardBufferLength (mut i32) (i32.const 0))
+ (global $buffer0ptr (mut i32) (i32.const -1))
+ (global $buffer1ptr (mut i32) (i32.const -1))
+ (global $currentBuffer (mut i32) (i32.const -1))
+
+ (func (export "initializeBoard") (param $width i32) (param $height i32)
+ ;; Store width and height for later
+ (global.set $boardWidth (local.get $width))
+ (global.set $boardHeight (local.get $height))
+
+ ;; Compute total cells per board
+ local.get $width
+ local.get $height
+ i32.mul
+ global.set $boardBufferLength
+
+ ;; Request enough memory for both boards
+ global.get $boardBufferLength
+ i32.const 2
+ i32.mul
+ call $growMemoryForBoards
+
+ ;; Set pointer locations for our two boards
+ (global.set $buffer0ptr (i32.const 0))
+ (global.set $buffer1ptr (global.get $boardBufferLength))
+
+ ;; Set current board
+ (global.set $currentBuffer (i32.const 0))
+ )
+
+ (func (export "getPagesUsed") (result i32)
+ memory.size
+ )
+
+ (func $growMemoryForBoards (param $totalBytes i32)
+ (local $targetPages i32)
+
+ ;; figure out target page size
+ local.get $totalBytes
+ i32.const 1
+ i32.sub
+ i32.const 65536 ;; size of a page
+ i32.div_u
+ i32.const 1
+ i32.add
+
+ ;; get difference
+ memory.size
+ i32.sub
+
+ ;; grow
+ memory.grow
+ drop ;; ignore result, we're gonna crash anyways
+ ;; perhaps we should have a way to report errors back to JS next time
+ )
+
+ (func $getBoardPtr (result i32)
+ global.get $currentBuffer
+ i32.eqz
+ if (result i32)
+ global.get $buffer0ptr
+ else
+ global.get $buffer1ptr
+ end
+ )
+
+ (func $swapBoards
+ global.get $currentBuffer
+ i32.eqz
+ if (result i32)
+ i32.const 1
+ else
+ i32.const 0
+ end
+ global.set $currentBuffer
+ )
+
+ (func $positionInRange (param $position i32) (param $min i32) (param $max i32) (result i32)
+ local.get $position
+ local.get $min
+ i32.lt_s
+
+ if
+ i32.const 0
+ return
+ end
+
+ local.get $position
+ local.get $max
+ i32.ge_s
+
+ if
+ i32.const 0
+ return
+ end
+
+ i32.const 1
+ return
+ )
+
+ (func $getIndexForPosition (param $row i32) (param $column i32) (result i32)
+ local.get $row
+ i32.const 0
+ global.get $boardHeight
+ call $positionInRange
+
+ local.get $column
+ i32.const 0
+ global.get $boardWidth
+ call $positionInRange
+
+ i32.and
+ i32.eqz
+
+ if
+ i32.const -1
+ return
+ end
+
+ global.get $boardWidth
+ local.get $row
+ i32.mul
+ local.get $column
+ i32.add
+ )
+
+ (func $getValueAtPosition (export "getValueAtPosition") (param $row i32) (param $column i32) (result i32)
+ (local $position i32)
+ local.get $row
+ local.get $column
+ call $getIndexForPosition
+
+ local.tee $position
+ i32.const 0
+ i32.lt_s
+
+ if
+ i32.const 0
+ return
+ end
+
+ local.get $position
+
+ call $getBoardPtr
+ i32.add
+ i32.load8_u
+ )
+
+ (func $setValueAtPosition (export "setValueAtPosition") (param $row i32) (param $column i32) (param $value i32)
+ (local $position i32)
+ local.get $row
+ local.get $column
+ call $getIndexForPosition
+
+ local.tee $position
+ i32.const 0
+ i32.lt_s
+
+ if
+ return
+ end
+
+ local.get $position
+
+ call $getBoardPtr
+ i32.add
+ local.get $value
+ i32.store8
+ )
+
+ (func $getNewValueAtPosition (param $row i32) (param $column i32) (result i32)
+ (local $count i32)
+
+ local.get $row
+ i32.const 1
+ i32.sub
+ local.get $column
+ call $getValueAtPosition
+
+ local.get $row
+ i32.const 1
+ i32.add
+ local.get $column
+ call $getValueAtPosition
+
+ local.get $row
+ local.get $column
+ i32.const 1
+ i32.sub
+ call $getValueAtPosition
+
+ local.get $row
+ local.get $column
+ i32.const 1
+ i32.add
+ call $getValueAtPosition
+
+ local.get $row
+ i32.const 1
+ i32.sub
+ local.get $column
+ i32.const 1
+ i32.sub
+ call $getValueAtPosition
+
+ local.get $row
+ i32.const 1
+ i32.add
+ local.get $column
+ i32.const 1
+ i32.sub
+ call $getValueAtPosition
+
+ local.get $row
+ i32.const 1
+ i32.sub
+ local.get $column
+ i32.const 1
+ i32.add
+ call $getValueAtPosition
+
+ local.get $row
+ i32.const 1
+ i32.add
+ local.get $column
+ i32.const 1
+ i32.add
+ call $getValueAtPosition
+
+ i32.add
+ i32.add
+ i32.add
+ i32.add
+ i32.add
+ i32.add
+ i32.add
+
+ ;; Exactly 3 neighbors
+ local.tee $count
+ i32.const 3
+ i32.eq
+
+ if
+ ;; becomes or stays alive
+ i32.const 1
+ return
+ end
+
+ ;; If currently dead
+ local.get $row
+ local.get $column
+ call $getValueAtPosition
+ i32.eqz
+ if
+ ;; Stay dead
+ i32.const 0
+ return
+ end
+
+ ;; 2 neighbors
+ local.get $count
+ i32.const 2
+ i32.eq
+ if
+ i32.const 1
+ return
+ end
+
+ i32.const 0
+ return
+ )
+
+ (func $tick (export "tick")
+ (local $row i32)
+ (local $column i32)
+ (local $value i32)
+
+ i32.const 0
+ local.set $row
+
+ loop $rows
+
+ ;; start at the beginning of a row
+ i32.const 0
+ local.set $column
+
+ ;; for every column in the row
+ loop $columns
+
+ ;; compute new value
+ local.get $row
+ local.get $column
+ call $getNewValueAtPosition
+ local.set $value
+
+ ;; place in next board
+ call $swapBoards
+ local.get $row
+ local.get $column
+ local.get $value
+ call $setValueAtPosition
+ call $swapBoards
+
+ ;; increment column
+ local.get $column
+ i32.const 1
+ i32.add
+ local.tee $column
+
+ ;; loop back if less than width
+ global.get $boardWidth
+ i32.lt_s
+ br_if $columns
+ end
+
+ ;;increment row
+ local.get $row
+ i32.const 1
+ i32.add
+ local.tee $row
+
+ ;; loop back if less than height
+ global.get $boardHeight
+ i32.lt_s
+ br_if $rows
+ end
+
+ ;; swap to the new board
+ call $swapBoards
+ )
+)
+