summaryrefslogtreecommitdiff
path: root/api/canvas
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /api/canvas
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'api/canvas')
-rw-r--r--api/canvas/canvas.go373
-rw-r--r--api/canvas/canvas_test.go83
2 files changed, 456 insertions, 0 deletions
diff --git a/api/canvas/canvas.go b/api/canvas/canvas.go
new file mode 100644
index 0000000..717d924
--- /dev/null
+++ b/api/canvas/canvas.go
@@ -0,0 +1,373 @@
+// Package canvas is a single shared r/place-style grid. The append-only pixel
+// log in the database is the source of truth; periodic binary snapshots let a
+// new client load the current state without replaying all of history.
+package canvas
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+)
+
+const (
+ Rows = 64
+ Cols = 64
+ Scale = 8
+
+ placeCooldown = 2 * time.Second
+ snapshotThreshold = 50
+ defaultColor = 0xffffff
+
+ writeWait = 10 * time.Second
+ pongWait = 60 * time.Second
+ pingPeriod = (pongWait * 9) / 10
+ maxMessageSize = 256
+)
+
+// r/place 2017 palette.
+var palette = []int{
+ 0xffffff, 0xe4e4e4, 0x888888, 0x222222,
+ 0xffa7d1, 0xe50000, 0xe59500, 0xa06a42,
+ 0xe5d900, 0x94e044, 0x02be01, 0x00d3dd,
+ 0x0083c7, 0x0000ea, 0xcf6ee4, 0x820080,
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+type inbound struct {
+ Type string `json:"type"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ I int `json:"i"`
+}
+
+type outbound struct {
+ Type string `json:"type"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ C int `json:"c"`
+ Ms int64 `json:"ms,omitempty"`
+}
+
+// ---- snapshot / state ------------------------------------------------------
+
+func blankCanvas() []byte {
+ buf := make([]byte, Rows*Cols*3)
+ for i := 0; i < len(buf); i += 3 {
+ buf[i] = byte(defaultColor >> 16)
+ buf[i+1] = byte((defaultColor >> 8) & 0xff)
+ buf[i+2] = byte(defaultColor & 0xff)
+ }
+ return buf
+}
+
+func setPixel(buf []byte, x, y, color int) {
+ if x < 0 || x >= Cols || y < 0 || y >= Rows {
+ return
+ }
+ i := (y*Cols + x) * 3
+ buf[i] = byte(color >> 16)
+ buf[i+1] = byte(color >> 8)
+ buf[i+2] = byte(color)
+}
+
+// baseline returns the latest snapshot's bytes and through-id, or a blank canvas.
+func baseline(db *sql.DB) ([]byte, int64, error) {
+ snap, err := database.LatestCanvasSnapshot(db)
+ if err != nil {
+ return nil, 0, err
+ }
+ if snap != nil && len(snap.Data) == Rows*Cols*3 {
+ buf := make([]byte, len(snap.Data))
+ copy(buf, snap.Data)
+ return buf, snap.ThroughID, nil
+ }
+ return blankCanvas(), 0, nil
+}
+
+func currentCanvas(db *sql.DB) ([]byte, error) {
+ buf, through, err := baseline(db)
+ if err != nil {
+ return nil, err
+ }
+ pixels, err := database.PixelsAfter(db, through)
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range pixels {
+ setPixel(buf, p.X, p.Y, p.Color)
+ }
+ return buf, nil
+}
+
+func takeSnapshot(db *sql.DB) error {
+ buf, through, err := baseline(db)
+ if err != nil {
+ return err
+ }
+ pixels, err := database.PixelsAfter(db, through)
+ if err != nil {
+ return err
+ }
+ if len(pixels) == 0 {
+ return nil
+ }
+
+ maxID := through
+ for _, p := range pixels {
+ setPixel(buf, p.X, p.Y, p.Color)
+ if p.ID > maxID {
+ maxID = p.ID
+ }
+ }
+ if err := database.SaveCanvasSnapshot(db, buf, maxID); err != nil {
+ return err
+ }
+ return database.DeleteOldCanvasSnapshots(db)
+}
+
+// ---- hub -------------------------------------------------------------------
+
+type Hub struct {
+ db *sql.DB
+ clients map[*Client]bool
+ register chan *Client
+ unregister chan *Client
+ broadcast chan []byte
+ place chan placement
+ lastPlaced map[string]time.Time
+ sinceSnapshot int
+}
+
+type placement struct {
+ client *Client
+ x, y, i int
+}
+
+func NewHub(db *sql.DB) *Hub {
+ return &Hub{
+ db: db,
+ clients: make(map[*Client]bool),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ broadcast: make(chan []byte),
+ place: make(chan placement),
+ lastPlaced: make(map[string]time.Time),
+ }
+}
+
+func (h *Hub) Run() {
+ if _, through, err := baseline(h.db); err == nil {
+ if n, err := database.CountPixelsAfter(h.db, through); err == nil {
+ h.sinceSnapshot = n
+ }
+ }
+
+ for {
+ select {
+ case client := <-h.register:
+ h.clients[client] = true
+ case client := <-h.unregister:
+ if _, ok := h.clients[client]; ok {
+ delete(h.clients, client)
+ close(client.send)
+ }
+ case payload := <-h.broadcast:
+ h.deliver(payload)
+ case p := <-h.place:
+ h.handlePlace(p)
+ }
+ }
+}
+
+func (h *Hub) deliver(payload []byte) {
+ for client := range h.clients {
+ select {
+ case client.send <- payload:
+ default:
+ delete(h.clients, client)
+ close(client.send)
+ }
+ }
+}
+
+func (h *Hub) sendTo(client *Client, msg outbound) {
+ if payload, err := json.Marshal(msg); err == nil {
+ select {
+ case client.send <- payload:
+ default:
+ }
+ }
+}
+
+func (h *Hub) handlePlace(p placement) {
+ if p.x < 0 || p.x >= Cols || p.y < 0 || p.y >= Rows || p.i < 0 || p.i >= len(palette) {
+ return
+ }
+
+ now := time.Now()
+ if last, ok := h.lastPlaced[p.client.userID]; ok {
+ if remaining := placeCooldown - now.Sub(last); remaining > 0 {
+ h.sendTo(p.client, outbound{Type: "cooldown", Ms: remaining.Milliseconds()})
+ return
+ }
+ }
+
+ color := palette[p.i]
+ if _, err := database.SavePixel(h.db, p.client.userID, p.x, p.y, color); err != nil {
+ log.Println("canvas: save pixel failed:", err)
+ return
+ }
+ h.lastPlaced[p.client.userID] = now
+
+ if payload, err := json.Marshal(outbound{Type: "place", X: p.x, Y: p.y, C: color}); err == nil {
+ h.deliver(payload)
+ }
+
+ h.sinceSnapshot++
+ if h.sinceSnapshot >= snapshotThreshold {
+ if err := takeSnapshot(h.db); err != nil {
+ log.Println("canvas: snapshot failed:", err)
+ } else {
+ h.sinceSnapshot = 0
+ }
+ }
+}
+
+// ---- client ----------------------------------------------------------------
+
+type Client struct {
+ hub *Hub
+ conn *websocket.Conn
+ send chan []byte
+ userID string
+}
+
+func (c *Client) readPump() {
+ defer func() {
+ c.hub.unregister <- c
+ c.conn.Close()
+ }()
+
+ c.conn.SetReadLimit(maxMessageSize)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error {
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ for {
+ _, raw, err := c.conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ var in inbound
+ if json.Unmarshal(raw, &in) != nil || in.Type != "place" {
+ continue
+ }
+ c.hub.place <- placement{client: c, x: in.X, y: in.Y, i: in.I}
+ }
+}
+
+func (c *Client) writePump() {
+ ticker := time.NewTicker(pingPeriod)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+
+ for {
+ select {
+ case payload, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+ if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
+
+// ---- continuations ---------------------------------------------------------
+
+type pageConfig struct {
+ Rows int
+ Cols int
+ Scale int
+ CooldownMs int64
+ Palette []string
+}
+
+func paletteHex() []string {
+ out := make([]string, len(palette))
+ for i, c := range palette {
+ out[i] = fmt.Sprintf("#%06x", c)
+ }
+ return out
+}
+
+func PageContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ (*context.TemplateData)["Canvas"] = pageConfig{
+ Rows: Rows,
+ Cols: Cols,
+ Scale: Scale,
+ CooldownMs: placeCooldown.Milliseconds(),
+ Palette: paletteHex(),
+ }
+ return success(context, req, resp)
+ }
+}
+
+func StateContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ buf, err := currentCanvas(context.DBConn)
+ if err != nil {
+ log.Println("canvas: state failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return success(context, req, resp)
+ }
+ resp.Header().Set("Content-Type", "application/octet-stream")
+ resp.Write(buf)
+ return success(context, req, resp)
+ }
+}
+
+func ServeWSContinuation(hub *Hub) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ conn, err := upgrader.Upgrade(resp, req, nil)
+ if err != nil {
+ log.Println("canvas: upgrade failed", err)
+ return failure(context, req, resp)
+ }
+
+ client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256), userID: context.User.ID}
+ hub.register <- client
+ go client.writePump()
+ go client.readPump()
+
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/canvas/canvas_test.go b/api/canvas/canvas_test.go
new file mode 100644
index 0000000..324a3a1
--- /dev/null
+++ b/api/canvas/canvas_test.go
@@ -0,0 +1,83 @@
+package canvas
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ "penguins.lan/database"
+)
+
+func canvasTestDB(t *testing.T) *sql.DB {
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "c.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := database.Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func pixelAt(buf []byte, x, y int) int {
+ i := (y*Cols + x) * 3
+ return int(buf[i])<<16 | int(buf[i+1])<<8 | int(buf[i+2])
+}
+
+func TestCurrentCanvasReplaysPixels(t *testing.T) {
+ db := canvasTestDB(t)
+
+ if _, err := database.SavePixel(db, "u", 1, 2, 0xe50000); err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := currentCanvas(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := pixelAt(buf, 1, 2); got != 0xe50000 {
+ t.Errorf("placed pixel = %06x, want e50000", got)
+ }
+ if got := pixelAt(buf, 0, 0); got != defaultColor {
+ t.Errorf("untouched pixel = %06x, want white", got)
+ }
+}
+
+func TestSnapshotBakesThenReplays(t *testing.T) {
+ db := canvasTestDB(t)
+
+ if _, err := database.SavePixel(db, "u", 5, 5, 0x000000); err != nil {
+ t.Fatal(err)
+ }
+ if err := takeSnapshot(db); err != nil {
+ t.Fatal(err)
+ }
+ // a placement after the snapshot must be replayed on top of it
+ if _, err := database.SavePixel(db, "u", 6, 6, 0x02be01); err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := currentCanvas(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := pixelAt(buf, 5, 5); got != 0x000000 {
+ t.Errorf("baked pixel = %06x, want black", got)
+ }
+ if got := pixelAt(buf, 6, 6); got != 0x02be01 {
+ t.Errorf("replayed pixel = %06x, want green", got)
+ }
+
+ // snapshots are kept to one row
+ if err := takeSnapshot(db); err != nil {
+ t.Fatal(err)
+ }
+ var n int
+ if err := db.QueryRow("SELECT COUNT(*) FROM canvas_snapshots").Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Errorf("snapshot rows = %d, want 1", n)
+ }
+}