Update via to v0.4.0 and decouple tick rate from snake speed

Use via.OnKeyDownMap for snake keybindings, replacing the manual
dataExpr/rawDataAttr workaround. Window-scoped key handling removes
the need for tabindex/focus hacks, and WithPreventDefault on arrow
keys prevents page scrolling during gameplay.

Introduce a 60 FPS tick loop with a separate snake movement speed
(7 cells/s) so direction input is polled every frame but game state
only advances at the configured rate.
This commit is contained in:
Ryan Hamamura
2026-02-02 09:18:13 -10:00
parent 7e78664534
commit 038c4b3f22
4 changed files with 28 additions and 70 deletions

4
go.mod
View File

@@ -5,8 +5,9 @@ go 1.25.4
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
github.com/ryanhamamura/via v0.3.1 github.com/ryanhamamura/via v0.4.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
maragu.dev/gomponents v1.2.0
modernc.org/sqlite v1.44.0 modernc.org/sqlite v1.44.0
) )
@@ -42,7 +43,6 @@ require (
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
maragu.dev/gomponents v1.2.0 // indirect
modernc.org/libc v1.67.4 // indirect modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

4
go.sum
View File

@@ -76,8 +76,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/ryanhamamura/via v0.3.1 h1:op/t/0YzpPhA68+FOyazMpdZVOCA6S7DGz0eWhXXQAA= github.com/ryanhamamura/via v0.4.0 h1:/8gfjcPhTl+SEYTPF+Guc6qB2vuW+FtNRQv+HpkV2k8=
github.com/ryanhamamura/via v0.3.1/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A= github.com/ryanhamamura/via v0.4.0/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU= github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=

74
main.go
View File

@@ -1,19 +1,13 @@
package main package main
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
_ "embed" _ "embed"
"fmt"
"html"
"io"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
g "maragu.dev/gomponents"
"github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/auth"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db"
@@ -33,37 +27,6 @@ var queries *gen.Queries
//go:embed assets/css/output.css //go:embed assets/css/output.css
var daisyUICSS []byte var daisyUICSS []byte
// dataExpr renders an h.H attribute node and extracts the raw Datastar expression.
// h.Data/h.Attr HTML-escape values, so we unescape to get the original expression.
func dataExpr(node h.H) string {
var buf bytes.Buffer
node.Render(&buf)
s := buf.String()
start := strings.Index(s, `="`) + 2
end := strings.LastIndex(s, `"`)
if start < 2 || end <= start {
return ""
}
return html.UnescapeString(s[start:end])
}
// rawDataAttr outputs an unescaped data attribute. Needed because gomponents
// HTML-escapes attribute values, which double-escapes expressions extracted
// from rendered nodes. Implements gomponents' attribute interface so it
// renders in the element's opening tag rather than as a child.
type rawDataAttr struct {
name, value string
}
func (a rawDataAttr) Render(w io.Writer) error {
_, err := fmt.Fprintf(w, ` %s="%s"`, a.name, a.value)
return err
}
func (a rawDataAttr) Type() g.NodeType {
return g.AttributeType
}
func DaisyUIPlugin(v *via.V) { func DaisyUIPlugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) { v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css") w.Header().Set("Content-Type", "text/css")
@@ -627,35 +590,18 @@ func main() {
content = append(content, ui.SnakeInviteLink(sg.ID)) content = append(content, ui.SnakeInviteLink(sg.ID))
} }
// Build keydown attributes with unique __suffix names so the
// browser doesn't deduplicate them (all share data-on:keydown).
type keyBinding struct {
suffix string
key string
dir snake.Direction
}
bindings := []keyBinding{
{"arrowup", "ArrowUp", snake.DirUp},
{"arrowdown", "ArrowDown", snake.DirDown},
{"arrowleft", "ArrowLeft", snake.DirLeft},
{"arrowright", "ArrowRight", snake.DirRight},
{"w", "w", snake.DirUp},
{"s", "s", snake.DirDown},
{"a", "a", snake.DirLeft},
{"d", "d", snake.DirRight},
}
wrapperAttrs := []h.H{ wrapperAttrs := []h.H{
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"), h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
h.Attr("tabindex", "0"), via.OnKeyDownMap(
h.Data("on:load", "this.focus()"), via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp))),
} via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft))),
for _, kb := range bindings { via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown))),
expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir)))) via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight))),
wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{ via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault()),
name: "data-on:keydown__" + kb.suffix, via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault()),
value: expr, via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault()),
})) via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault()),
),
} }
wrapperAttrs = append(wrapperAttrs, content...) wrapperAttrs = append(wrapperAttrs, content...)

View File

@@ -5,7 +5,10 @@ import (
) )
const ( const (
tickInterval = 500 * time.Millisecond targetFPS = 60
tickInterval = time.Second / targetFPS
snakeSpeed = 7 // cells per second
moveInterval = time.Second / snakeSpeed
countdownSeconds = 10 countdownSeconds = 10
inactivityLimit = 60 * time.Second inactivityLimit = 60 * time.Second
) )
@@ -91,6 +94,7 @@ func (si *SnakeGameInstance) gamePhase() {
defer ticker.Stop() defer ticker.Stop()
lastInput := time.Now() lastInput := time.Now()
var moveAccum time.Duration
for { for {
select { select {
@@ -104,7 +108,7 @@ func (si *SnakeGameInstance) gamePhase() {
return return
} }
// Apply pending directions (iterate all 8 slots) // Apply pending directions every tick for responsive input
inputReceived := false inputReceived := false
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil { if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
@@ -128,6 +132,14 @@ func (si *SnakeGameInstance) gamePhase() {
return return
} }
// Only advance game state at snakeSpeed
moveAccum += tickInterval
if moveAccum < moveInterval {
si.gameMu.Unlock()
continue
}
moveAccum -= moveInterval
state := si.game.State state := si.game.State
// Advance snakes // Advance snakes