main.go
// Command sudoku is a full Sudoku game: tap a cell (or arrow-key to it) and
// type 1-9 to fill it, toggle Notes for pencil marks, and get live conflict
// highlighting. Puzzles are generated with a guaranteed-unique solution. It is
// one widget.Canvas driving the board plus an on-screen number pad, so it plays
// with a keyboard on desktop and by touch on mobile.
//
// go run ./examples/sudoku
package main
import (
"log"
"math/rand"
"time"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// palette is the board's resolved chrome — the colors the Canvas Draw func needs,
// captured from the active theme.Theme each Build so the whole game follows the
// platform light/dark scheme. The signature Sudoku semantics (player blue,
// conflict red, selection/peer/same washes) are preserved but re-expressed in
// theme tokens so they read on both light and dark cells. Cell washes are Lerped
// onto Surface (not alpha-over-bg) so an empty highlighted cell stays an opaque,
// legible surface color in either scheme.
type palette struct {
bg paint.Color // page/window background
cellBg paint.Color // empty cell
selBg paint.Color // selected cell wash
peerBg paint.Color // same row/col/box wash
sameBg paint.Color // matching-value wash
badBg paint.Color // conflict cell wash
ink paint.Color // given clue + title text
player paint.Color // player-entered digit (blue → Primary)
badFg paint.Color // conflict digit (red → Danger)
noteInk paint.Color // pencil marks
lineThin paint.Color // thin grid lines
lineThick paint.Color // thick box lines
btnBg paint.Color // number pad + control button chrome
accent paint.Color // active control / notes-on
onAccent paint.Color // label on an active (accent) control
disabled paint.Color // exhausted number-pad digit
good paint.Color // "Solved!" status
sub paint.Color // idle status
}
// paletteFrom maps the theme tokens onto the Sudoku chrome, keeping the game's
// signature look while adapting it for contrast in both schemes.
func paletteFrom(th theme.Theme) palette {
surf := th.Surface
return palette{
bg: th.Bg,
cellBg: surf,
selBg: paint.Lerp(surf, th.Primary, 0.34),
peerBg: paint.Lerp(surf, th.Primary, 0.12),
sameBg: paint.Lerp(surf, th.Primary, 0.22),
badBg: paint.Lerp(surf, th.Danger, 0.28),
ink: th.Text,
player: th.Primary,
badFg: th.Danger,
noteInk: th.Muted,
lineThin: th.Border,
lineThick: th.Muted, // darker/stronger box divider
btnBg: surf,
accent: th.Primary,
onAccent: th.OnPrimary,
disabled: th.Muted,
good: th.Success,
sub: th.Muted,
}
}
// clueTarget is how few givens generation aims for (uniqueness may keep more).
const clueTarget = 32
type Game struct{}
func (Game) CreateState() widget.State { return &game{} }
type game struct {
widget.StateBase[Game]
rng *rand.Rand
puzzle Grid
solution Grid
board Grid // current player state; starts as puzzle
given [81]bool // fixed clue cells
notes [81]uint16 // pencil marks, bit v set = note v present
sel int // selected cell index, -1 = none
noteMode bool
won bool
pal palette // chrome colors captured from the active theme in Build
ctx widget.Ctx
boardRect geom.Rect // set during draw, hit-tested on press
cell float32
numBtn [9]geom.Rect
notesBtn geom.Rect
eraseBtn geom.Rect
newBtn geom.Rect
}
// stateHook, if set, receives the game state on mount — for tests to drive and
// inspect input end to end.
var stateHook func(*game)
func (s *game) Init(ctx widget.Ctx) {
s.ctx = ctx
s.rng = rand.New(rand.NewSource(time.Now().UnixNano()))
s.reset()
if stateHook != nil {
stateHook(s)
}
}
func (s *game) reset() {
s.puzzle, s.solution = generate(s.rng, clueTarget)
s.board = s.puzzle
for i := range 81 {
s.given[i] = s.puzzle[i] != 0
s.notes[i] = 0
}
s.sel = -1
s.noteMode = false
s.won = false
}
// input places digit v (1..9) in the selected cell, honoring note mode and
// leaving clue cells untouched.
func (s *game) input(v int) {
if s.sel < 0 || s.given[s.sel] || s.won {
return
}
if s.noteMode {
if s.board[s.sel] == 0 {
s.notes[s.sel] ^= 1 << uint(v) // toggle the pencil mark
}
} else if s.board[s.sel] == v {
s.board[s.sel] = 0 // typing the same digit clears it
} else {
s.board[s.sel] = v
s.notes[s.sel] = 0
s.clearPeerNotes(s.sel, v)
if s.board.solved() {
s.won = true
}
}
s.ctx.Invalidate()
}
// clearPeerNotes removes v from the pencil marks of every cell that shares a
// row, column, or box with i — the tidy-up a player would do by hand.
func (s *game) clearPeerNotes(i, v int) {
r, c := i/9, i%9
mask := ^(uint16(1) << uint(v))
for j := range 9 {
s.notes[idx(r, j)] &= mask
s.notes[idx(j, c)] &= mask
}
br, bc := r/3*3, c/3*3
for dr := range 3 {
for dc := range 3 {
s.notes[idx(br+dr, bc+dc)] &= mask
}
}
}
func (s *game) erase() {
if s.sel < 0 || s.given[s.sel] || s.won {
return
}
s.board[s.sel] = 0
s.notes[s.sel] = 0
s.ctx.Invalidate()
}
// move steps the selection by (dc,dr) cells, clamped to the board.
func (s *game) move(dc, dr int) {
if s.sel < 0 {
s.sel = idx(4, 4)
} else {
r := clampi(s.sel/9+dr, 0, 8)
c := clampi(s.sel%9+dc, 0, 8)
s.sel = idx(r, c)
}
s.ctx.Invalidate()
}
func (s *game) toggleNotes() {
s.noteMode = !s.noteMode
s.ctx.Invalidate()
}
func (s *game) onPress(p geom.Pt) {
if s.boardRect.Contains(p) {
c := int((p.X - s.boardRect.Min.X) / s.cell)
r := int((p.Y - s.boardRect.Min.Y) / s.cell)
if r >= 0 && r < 9 && c >= 0 && c < 9 {
s.sel = idx(r, c)
s.ctx.Invalidate()
}
return
}
for v := range 9 {
if s.numBtn[v].Contains(p) {
s.input(v + 1)
return
}
}
switch {
case s.notesBtn.Contains(p):
s.toggleNotes()
case s.eraseBtn.Contains(p):
s.erase()
case s.newBtn.Contains(p):
s.reset()
s.ctx.Invalidate()
}
}
func (s *game) Build(ctx widget.Ctx) widget.Widget {
// Resolve the theme from the platform color scheme, capture the chrome colors
// for the Canvas Draw func (which has no ctx), and provide the theme so the
// whole game follows light/dark automatically.
th := theme.Auto(ctx)
s.pal = paletteFrom(th)
board := widget.Interactive{
Gestures: widget.Gestures{
OnKey: func(k shell.Key) {
if k.Kind != shell.KeyPress {
return
}
switch k.Code {
case shell.KeyLeft:
s.move(-1, 0)
case shell.KeyRight:
s.move(1, 0)
case shell.KeyUp:
s.move(0, -1)
case shell.KeyDown:
s.move(0, 1)
case shell.KeyBackspace, shell.KeyDelete:
s.erase()
case shell.KeySpace:
s.toggleNotes()
}
},
OnText: func(t string) {
for _, ch := range t {
if ch >= '1' && ch <= '9' {
s.input(int(ch - '0'))
}
}
},
OnPress: func(p geom.Pt) { s.onPress(p) },
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: board},
}
}
func (s *game) draw(c paint.Canvas, sz geom.Size) {
p := s.pal
c.Clear(p.bg)
const pad = 16
c.TextIn("", "Sudoku", geom.Pt{X: pad, Y: 34}, 24, p.ink)
if status, col := s.status(); status != "" {
w := s.ctx.Painter().MeasureWidthIn("", status, 16)
c.TextIn("", status, geom.Pt{X: sz.W - pad - w, Y: 34}, 16, col)
}
const belowH = 118 // number pad + control row
top := float32(52)
B := min(sz.W-2*pad, sz.H-top-belowH-pad)
if B < 90 {
return
}
bx, by := (sz.W-B)/2, top
cell := B / 9
s.boardRect = geom.RectXYWH(bx, by, B, B)
s.cell = cell
bad := s.board.conflicts()
selR, selC, selV := -1, -1, 0
if s.sel >= 0 {
selR, selC, selV = s.sel/9, s.sel%9, s.board[s.sel]
}
// Cell backgrounds: selection, conflicts, peers, matching value.
for i := range 81 {
r, cc := i/9, i%9
var col paint.Color
switch {
case i == s.sel:
col = p.selBg
case bad[i]:
col = p.badBg
case selR >= 0 && (r == selR || cc == selC || (r/3 == selR/3 && cc/3 == selC/3)):
col = p.peerBg
case selV != 0 && s.board[i] == selV:
col = p.sameBg
default:
col = p.cellBg
}
c.FillRect(geom.RectXYWH(bx+float32(cc)*cell, by+float32(r)*cell, cell, cell), col)
}
// Grid lines, thick every third for the boxes.
for k := 0; k <= 9; k++ {
off := float32(k) * cell
w, col := float32(1), p.lineThin
if k%3 == 0 {
w, col = 2.4, p.lineThick
}
c.Line(geom.Pt{X: bx, Y: by + off}, geom.Pt{X: bx + B, Y: by + off}, w, col)
c.Line(geom.Pt{X: bx + off, Y: by}, geom.Pt{X: bx + off, Y: by + B}, w, col)
}
// Digits and pencil marks.
for i := range 81 {
r, cc := i/9, i%9
x, y := bx+float32(cc)*cell, by+float32(r)*cell
if v := s.board[i]; v != 0 {
col := p.player
if s.given[i] {
col = p.ink
}
if bad[i] {
col = p.badFg
}
fs := cell * 0.62
c.TextIn("", digit(v), geom.Pt{X: x + cell/2 - fs*0.28, Y: y + cell/2 + fs*0.35}, fs, col)
} else if s.notes[i] != 0 {
ns := cell / 3
for v := 1; v <= 9; v++ {
if s.notes[i]&(1<<uint(v)) != 0 {
nx := x + float32((v-1)%3)*ns
ny := y + float32((v-1)/3)*ns
fs := ns * 0.72
c.TextIn("", digit(v), geom.Pt{X: nx + ns/2 - fs*0.28, Y: ny + ns/2 + fs*0.34}, fs, p.noteInk)
}
}
}
}
// Number pad: one button per digit, dimmed once all nine are placed.
counts := s.digitCounts()
padY := by + B + 14
padH := cell * 0.92
for v := 1; v <= 9; v++ {
rect := geom.RectXYWH(bx+float32(v-1)*cell+1.5, padY, cell-3, padH)
s.numBtn[v-1] = rect
c.FillRRect(rect, 6, p.btnBg)
fg := p.ink
if counts[v] >= 9 {
fg = p.disabled
}
fs := padH * 0.5
c.TextIn("", digit(v), geom.Pt{X: rect.Min.X + rect.Dx()/2 - fs*0.28, Y: rect.Min.Y + rect.Dy()/2 + fs*0.35}, fs, fg)
}
// Controls.
cy := padY + padH + 12
const ch, gap = 40, 8
third := (B - 2*gap) / 3
s.notesBtn = geom.RectXYWH(bx, cy, third, ch)
s.eraseBtn = geom.RectXYWH(bx+third+gap, cy, third, ch)
s.newBtn = geom.RectXYWH(bx+2*(third+gap), cy, third, ch)
s.button(c, s.notesBtn, "Notes", s.noteMode)
s.button(c, s.eraseBtn, "Erase", false)
s.button(c, s.newBtn, "New", false)
}
func (s *game) status() (string, paint.Color) {
switch {
case s.won:
return "Solved!", s.pal.good
case s.noteMode:
return "Notes on", s.pal.accent
default:
return "", s.pal.sub
}
}
func (s *game) button(c paint.Canvas, rect geom.Rect, label string, active bool) {
bgc, fg := s.pal.btnBg, s.pal.ink
if active {
bgc, fg = s.pal.accent, s.pal.onAccent
}
c.FillRRect(rect, 8, bgc)
w := s.ctx.Painter().MeasureWidthIn("", label, 14.5)
c.TextIn("", label, geom.Pt{X: rect.Min.X + (rect.Dx()-w)/2, Y: rect.Min.Y + rect.Dy()/2 + 5}, 14.5, fg)
}
func (s *game) digitCounts() [10]int {
var n [10]int
for i := range 81 {
n[s.board[i]]++
}
return n
}
// digit renders a single 1-9 value without importing strconv.
func digit(v int) string { return string(rune('0' + v)) }
func clampi(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func main() {
if err := app.Run(Game{}, app.Config{
Title: "Sudoku",
Size: geom.Size{W: 400, H: 600},
Background: theme.Light().Bg,
Font: goregular.TTF,
}); err != nil {
log.Fatal(err)
}
}
sudoku.go
package main
import "math/rand"
// Grid is a 9x9 Sudoku grid in row-major order; 0 is an empty cell.
type Grid [81]int
func idx(r, c int) int { return r*9 + c }
// canPlace reports whether v (1..9) can go at (r,c) without repeating in the
// row, column, or 3x3 box.
func canPlace(g *Grid, r, c, v int) bool {
for i := range 9 {
if g[idx(r, i)] == v || g[idx(i, c)] == v {
return false
}
}
br, bc := r/3*3, c/3*3
for dr := range 3 {
for dc := range 3 {
if g[idx(br+dr, bc+dc)] == v {
return false
}
}
}
return true
}
func (g *Grid) firstEmpty() int {
for i := range 81 {
if g[i] == 0 {
return i
}
}
return -1
}
// fill solves g in place by backtracking, trying digits in the order candidates
// returns (randomized during generation). Reports whether it fully solved.
func fill(g *Grid, candidates func() []int) bool {
i := g.firstEmpty()
if i < 0 {
return true
}
r, c := i/9, i%9
for _, v := range candidates() {
if canPlace(g, r, c, v) {
g[i] = v
if fill(g, candidates) {
return true
}
g[i] = 0
}
}
return false
}
// countSolutions counts solutions of g, stopping once it reaches limit — enough
// to tell "unique" (==1) from "ambiguous" (>1).
func countSolutions(g Grid, limit int) int {
n := 0
var rec func(*Grid)
rec = func(gg *Grid) {
if n >= limit {
return
}
i := gg.firstEmpty()
if i < 0 {
n++
return
}
r, c := i/9, i%9
for v := 1; v <= 9; v++ {
if canPlace(gg, r, c, v) {
gg[i] = v
rec(gg)
gg[i] = 0
if n >= limit {
return
}
}
}
}
rec(&g)
return n
}
// generate builds a random puzzle with a unique solution, removing clues toward
// targetClues while uniqueness holds. Returns the puzzle and its solution.
func generate(rng *rand.Rand, targetClues int) (puzzle, solution Grid) {
shuffled := func() []int {
d := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
return d
}
var full Grid
fill(&full, shuffled)
solution = full
puzzle = full
clues := 81
for _, i := range rng.Perm(81) {
if clues <= targetClues {
break
}
saved := puzzle[i]
puzzle[i] = 0
if countSolutions(puzzle, 2) == 1 {
clues--
} else {
puzzle[i] = saved // removing this clue made the puzzle ambiguous — keep it
}
}
return puzzle, solution
}
// conflicts marks each filled cell that repeats its value within its row,
// column, or box.
func (g *Grid) conflicts() [81]bool {
var bad [81]bool
for i := range 81 {
v := g[i]
if v == 0 {
continue
}
r, c := i/9, i%9
for j := range 9 {
if k := idx(r, j); k != i && g[k] == v {
bad[i] = true
}
if k := idx(j, c); k != i && g[k] == v {
bad[i] = true
}
}
br, bc := r/3*3, c/3*3
for dr := range 3 {
for dc := range 3 {
if k := idx(br+dr, bc+dc); k != i && g[k] == v {
bad[i] = true
}
}
}
}
return bad
}
// solved reports whether the grid is completely and validly filled.
func (g *Grid) solved() bool {
bad := g.conflicts()
for i := range 81 {
if g[i] == 0 || bad[i] {
return false
}
}
return true
}