From b7ec25fae26b09c3040a88c96a66fa2f8492b5a3 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 23 Oct 2025 15:41:02 -0400 Subject: [PATCH 1/4] Add a Conway's-game-of-life example. Hits the worker for every frame, which will perhaps be impressive. --- Makefile | 2 +- examples/README.md | 4 + examples/game-of-life.py | 183 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 examples/game-of-life.py diff --git a/Makefile b/Makefile index f8b63d3..007486d 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ BUILD_DIR := build EXAMPLES_DIR := examples # Define all available examples (add new ones here) -EXAMPLES := wit-bottle flask-app +EXAMPLES := wit-bottle flask-app game-of-life # Default example for serve target EXAMPLE ?= wit-bottle diff --git a/examples/README.md b/examples/README.md index 7463394..07601c6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,12 +14,16 @@ This directory contains example applications demonstrating different approaches - **Features**: Flask routing, request handling, error handling - **Use Case**: More complex applications, familiar Flask patterns +### `game-of-life.py` +A server-side implementation of Conway’s Game of Life, with a server round trip per frame. This demonstrates raw requests-per-second performance. + ## Building and Running Examples ### Build a Specific Example ```bash make build/flask-app.wasm # Build Flask example make build/wit-bottle.wasm # Build Bottle example +make build/game-of-life.wasm # Build Conway's Game of Life example ``` ### Serve an Example diff --git a/examples/game-of-life.py b/examples/game-of-life.py new file mode 100644 index 0000000..1eac17f --- /dev/null +++ b/examples/game-of-life.py @@ -0,0 +1,183 @@ +from random import random + +from flask import Flask + +try: + from fastly_compute.wsgi import WsgiHttpIncoming +except ImportError: + # We're running this using the Flask debug server. + running_under_compute = False +else: + running_under_compute = True + + +app = Flask(__name__) + + +# Board width and height. We assume a square board for now. +# 45 crashes. Viceroy will pass us no more than 1936 bytes of the board. (Or +# maybe the entire URL gets truncated at 1965b.) +WIDTH = HEIGHT = 44 + + +@app.route("/board/") +def board(cells): + """Return the next frame of the Game Of Life, given the current one. If a "" + board is given, return a new random board.""" + # Random board on start: + if cells == "none": + return "".join("1" if random() < 0.1 else "0" for _ in range(HEIGHT * WIDTH)) + + # Otherwise, evolve 1 step: + new_cells = "" + for i in range(len(cells)): + new_cells += new_cell_color(cells, i) + return new_cells + + +def new_cell_color(cells, cell_index): + """Compute the new color of a single cell at the given offset.""" + count = sum( + (cells[neighbor_index] != "0") for neighbor_index in neighbors(cell_index) + ) + if cells[cell_index] != "0": + if 2 <= count <= 3: + return str(count) + else: + return "0" + elif count == 3: + return "1" + return "0" + + +def xy_neighbors(x, y): + """Return an iterable of the x-y coordinates of all the adjacent cells, + omitting any that are outside the board bounds.""" + x_can_grow = x + 1 < WIDTH + y_can_grow = y + 1 < HEIGHT + x_can_shrink = x >= 1 + y_can_shrink = y >= 1 + if x_can_grow: + yield x + 1, y + if x_can_shrink: + yield x - 1, y + if y_can_grow: + yield x, y + 1 + if y_can_shrink: + yield x, y - 1 + if x_can_grow and y_can_grow: + yield x + 1, y + 1 + if x_can_grow and y_can_shrink: + yield x + 1, y - 1 + if x_can_shrink and y_can_grow: + yield x - 1, y + 1 + if x_can_shrink and y_can_shrink: + yield x - 1, y - 1 + + +def neighbors(cell_index): + """Return an iterable of the indices of all the adjacent cells, omitting any + that are outside the board bounds.""" + y, x = divmod(cell_index, WIDTH) + for neighbor_x, neighbor_y in xy_neighbors(x, y): + yield neighbor_y * WIDTH + neighbor_x + + +@app.route("/") +def root(): + return """ + + + + + Conway's Game of Life + + + + + + + +""" % {"width": WIDTH, "viewbox_width": 10 + WIDTH * 10} + + +if running_under_compute: + HttpIncoming = WsgiHttpIncoming(app) diff --git a/pyproject.toml b/pyproject.toml index b97c4ab..8617b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ select = [ ] ignore = [ "E501", # line too long, handled by formatter + "UP031", # % string formatting, minimally disruptive for stdlib-based HTML templating ] [tool.ruff.format] From d27adf2458fc05e9bdf846adaf809cfb8352b813 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Tue, 28 Oct 2025 11:22:05 -0400 Subject: [PATCH 2/4] Add board compression in the client-to-server direction, and expand board to 100x100. This lets us get boards over 44x44 to work in Viceroy, which has some URL-length limits. --- examples/game-of-life.py | 83 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/examples/game-of-life.py b/examples/game-of-life.py index 1eac17f..befd122 100644 --- a/examples/game-of-life.py +++ b/examples/game-of-life.py @@ -1,3 +1,4 @@ +from base64 import urlsafe_b64decode from random import random from flask import Flask @@ -16,14 +17,35 @@ # Board width and height. We assume a square board for now. # 45 crashes. Viceroy will pass us no more than 1936 bytes of the board. (Or -# maybe the entire URL gets truncated at 1965b.) -WIDTH = HEIGHT = 44 +# maybe the entire URL gets truncated at 1965b.) If you change this, change the +# f"{i:010000b}" format string below to be the new value squared. +WIDTH = HEIGHT = 100 -@app.route("/board/") -def board(cells): +def decompressed_board(compressed: str) -> str: + """Decompress the board representation sent from JS, returning a B&W board + string ("10011011"...). + + :arg compressed: A urlsafe_b64encode()d representation of the bit-packed + black-and-white board. (We don't need color info in order to compute the + next board.) + + This saves 83% space. RLE would save about 90% but would depend on the board + and would be slower to compute. + """ + if compressed == "none": + return "none" + i = int.from_bytes(urlsafe_b64decode(compressed)) + return f"{i:010000b}" + + +@app.route("/board/") +def board(compressed_board: str): """Return the next frame of the Game Of Life, given the current one. If a "" - board is given, return a new random board.""" + board is given, return a new random board. + """ + cells = decompressed_board(compressed_board) + # Random board on start: if cells == "none": return "".join("1" if random() < 0.1 else "0" for _ in range(HEIGHT * WIDTH)) @@ -101,7 +123,7 @@ def root(): "1": "#E44DA2", "2": "#86E9C9", "3": "#A577DF"}; - let board = "empty"; + let board = "none"; const max = %(width)s; let recent_boards = ["", "", "", "", "", "", ""]; let boredom_counter = 0; @@ -126,10 +148,55 @@ def root(): return grid; } + // Compress the board, shucking off color info and interpreting the + // remaining 1s and 0s as binary digits. Interpret as an int, and + // encode using URL-safe base64. + function compressedBoard(board) { + if (board == "none") { + return "none"; + } + + // Collapse to black-and-white: + let binary = board.replace(/2|3/g, "1"); + + // Pad to multiple of 8 bits if necessary: + binary = binary.padStart(Math.ceil(binary.length / 8) * 8, "0"); + + // Convert binary string to bytes: + const bytes = []; + for (let i = 0; i < binary.length; i += 8) { + const byte = binary.slice(i, i + 8); + bytes.push(parseInt(byte, 2)); + } + + // Convert bytes to URL-safe base64: + let ret = ""; + const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + for (let i = 0; i < bytes.length; i += 3) { + // Get next 3 bytes: + const byte1 = bytes[i] || 0; + const byte2 = bytes[i + 1] || 0; + const byte3 = bytes[i + 2] || 0; + + // Pack them into a 24-bit number: + const combined = (byte1 << 16) | (byte2 << 8) | byte3; + + // Pull out four 6-bit values: + const char1 = base64Chars[(combined >> 18) & 0x3F]; + const char2 = base64Chars[(combined >> 12) & 0x3F]; + const char3 = i + 1 < bytes.length ? base64Chars[(combined >> 6) & 0x3F] : "="; + const char4 = i + 2 < bytes.length ? base64Chars[combined & 0x3F] : "="; + + ret += char1 + char2 + char3 + char4; + } + return ret; + } + async function updateGrid(grid) { // Fetch new grid: // These days, max querystring URL length is 65K in Firefox. Only Edge is shorter, at 2083. - board = await (await fetch("./board/" + board)).text(); + board = await (await fetch("./board/" + compressedBoard(board))).text(); // Draw it: let i = 0; @@ -152,7 +219,7 @@ def root(): recent_boards.shift(); recent_boards.push(board); if (boredom_counter >= 10) { - board = "empty"; + board = "none"; // Has to be at least 7 long to detect the union of 3-tick and // 2-tick oscillators going at it: recent_boards = ["", "", "", "", "", "", ""]; From a6db004831591c16e905aa373295cbfc5ab48e39 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Tue, 28 Oct 2025 11:22:24 -0400 Subject: [PATCH 3/4] Add FPS counter. --- examples/game-of-life.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/examples/game-of-life.py b/examples/game-of-life.py index befd122..2776be5 100644 --- a/examples/game-of-life.py +++ b/examples/game-of-life.py @@ -118,6 +118,7 @@ def root(): +

FPS