package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Physics tuned so per-frame displacement stays around one row, which keeps // motion smooth and the 7-row gap threadable at 30fps. const ( fps = 30 gravity = 0.09 // rows/frame^2 jumpVel = -0.95 // rows/frame; peak rise ≈ v²/2g ≈ 5 rows over ~0.35s maxFall = 1.15 // terminal velocity, rows/frame — never skip >1 row pipeSpeed = 0.5 // cells/frame → 15 cells/s pipeGap = 7 pipeSpacing = 28 // cells between pipes → one pipe ≈ every 1.9s pipeWidth = 3 groundH = 2 ) // tickMsg advances the simulation. gen guards against stale tick chains: // without it, restarting can leave an old chain in flight and the game // silently runs at double speed. type tickMsg struct{ gen int } func tick(gen int) tea.Cmd { return tea.Tick(time.Second/fps, func(time.Time) tea.Msg { return tickMsg{gen: gen} }) } type pipe struct { x float64 gapY int scored bool } type star struct{ x, y int } type model struct { termW, termH int width int // field width in cells height int // field height in cells (terminal minus header+footer) birdX int birdY float64 birdVel float64 pipes []pipe stars []star score int best int gen int // tick generation gameOver bool started bool resized bool } func newGame(best, gen int, termW, termH int) model { m := model{best: best, gen: gen} if termW > 0 && termH > 0 { m.applySize(termW, termH) } return m } // applySize records the terminal size, sizes the field, and rebuilds the // static starfield. The starfield must be stable between frames — rolling // new random speckles every render is what caused full-screen flicker. func (m *model) applySize(w, h int) { m.termW, m.termH = w, h m.width = w fieldH := h - 2 // one row header, one row footer if fieldH < 8 { fieldH = 8 } m.height = fieldH m.birdX = w / 4 if m.birdX < 4 { m.birdX = 4 } if !m.resized { m.birdY = float64(m.height-groundH) / 2 m.resized = true } // keep the bird inside the new bounds after a resize floor := float64(m.height - groundH - 1) if m.birdY > floor { m.birdY = floor } // static starfield, ~1 speckle per 40 sky cells skyH := m.height - groundH m.stars = m.stars[:0] n := m.width * skyH / 40 for i := 0; i < n; i++ { m.stars = append(m.stars, star{x: rand.Intn(m.width), y: rand.Intn(skyH)}) } } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.applySize(msg.Width, msg.Height) return m, nil case tea.KeyMsg: switch msg.String() { case " ", "space", "enter", "up", "k": if m.gameOver { // restart: bump generation so any in-flight tick is ignored nm := newGame(m.best, m.gen+1, m.termW, m.termH) nm.started = true nm.birdVel = jumpVel // start with a flap, like the original return nm, tick(nm.gen) } if !m.started { m.started = true m.birdVel = jumpVel return m, tick(m.gen) } m.birdVel = jumpVel case "q", "esc", "ctrl+c": return m, tea.Quit } case tickMsg: if msg.gen != m.gen { return m, nil // stale chain — drop it } if !m.started || m.gameOver { return m, nil // stop the loop; nothing animates on end screens } if m.resized { m.step() } return m, tick(m.gen) } return m, nil } func (m *model) step() { m.birdVel += gravity if m.birdVel > maxFall { m.birdVel = maxFall } m.birdY += m.birdVel // ceiling clamp if m.birdY < 0 { m.birdY = 0 m.birdVel = 0 } // ground collision floor := float64(m.height - groundH - 1) if m.birdY >= floor { m.birdY = floor m.gameOver = true } // advance pipes for i := range m.pipes { m.pipes[i].x -= pipeSpeed } for len(m.pipes) > 0 && m.pipes[0].x+pipeWidth < 0 { m.pipes = m.pipes[1:] } // spawn pipes usableH := m.height - groundH - pipeGap - 4 if usableH < 1 { usableH = 1 } if len(m.pipes) == 0 || m.pipes[len(m.pipes)-1].x < float64(m.width-pipeSpacing) { gapY := rand.Intn(usableH) + 2 m.pipes = append(m.pipes, pipe{x: float64(m.width), gapY: gapY}) } by := int(m.birdY) for i := range m.pipes { p := &m.pipes[i] px := int(p.x) // score once the pipe's trailing edge passes the bird if !p.scored && px+pipeWidth <= m.birdX { p.scored = true m.score++ if m.score > m.best { m.best = m.score } } // collision with pipe body if m.birdX >= px && m.birdX < px+pipeWidth { if by < p.gapY || by > p.gapY+pipeGap { m.gameOver = true } } } } // styles ---------------------------------------------------------------------- var ( birdStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) birdUpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) birdDnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) pipeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) pipeHi = lipgloss.NewStyle().Foreground(lipgloss.Color("46")) capStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36")) skyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33")) groundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("142")) dirtStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) bestStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) overStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) titleStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("220")). Background(lipgloss.Color("23")). Bold(true) ) func birdGlyph(vel float64) (string, lipgloss.Style) { switch { case vel < -0.3: return "➚", birdUpStyle case vel > 0.5: return "➘", birdDnStyle default: return "➙", birdStyle } } func (m model) View() string { if !m.resized { return hintStyle.Render(" sizing terminal… ") } width, height := m.width, m.height grid := make([][]string, height) for i := range grid { grid[i] = make([]string, width) for j := range grid[i] { grid[i][j] = " " } } // static sky speckles (precomputed — stable between frames) for _, s := range m.stars { if s.y < height-groundH && s.x < width { grid[s.y][s.x] = skyStyle.Render("·") } } // pipes for _, p := range m.pipes { px := int(p.x) topCap := p.gapY - 1 botCap := p.gapY + pipeGap + 1 for x := px; x < px+pipeWidth && x < width; x++ { if x < 0 { continue } for y := 0; y < height-groundH; y++ { if y < p.gapY || y > p.gapY+pipeGap { if y == topCap || y == botCap { grid[y][x] = capStyle.Render("▓") } else if x == px { grid[y][x] = pipeHi.Render("█") } else { grid[y][x] = pipeStyle.Render("█") } } } } } // ground for y := height - groundH; y < height; y++ { for x := 0; x < width; x++ { if y == height-groundH { grid[y][x] = groundStyle.Render("▔") } else { grid[y][x] = dirtStyle.Render("▓") } } } // bird by := int(m.birdY) if by >= 0 && by < height && m.birdX >= 0 && m.birdX < width { glyph, style := birdGlyph(m.birdVel) grid[by][m.birdX] = style.Render(glyph) } // overlays are written directly into the grid, cell by cell, so styled // field content underneath can never be corrupted by string splicing. if !m.started && !m.gameOver { m.overlayGrid(grid, []overline{ {" FLAPPY BIRD ", titleStyle}, {}, {"press SPACE to flap", hintStyle}, }) } else if m.gameOver { m.overlayGrid(grid, []overline{ {"✖ GAME OVER ✖", overStyle}, {}, {fmt.Sprintf("score %d • SPACE to retry", m.score), hintStyle}, }) } // compose field var b strings.Builder for _, row := range grid { for _, c := range row { b.WriteString(c) } b.WriteString("\n") } field := b.String() header := scoreStyle.Render(fmt.Sprintf(" SCORE %d ", m.score)) + " " + bestStyle.Render(fmt.Sprintf("BEST %d ", m.best)) footer := hintStyle.Render(" SPACE: flap • Q: quit ") return header + "\n" + field + footer } // overline is one centred line of overlay text with its style. type overline struct { text string style lipgloss.Style } // overlayGrid writes overlay lines into the middle of the grid, one styled // cell per rune. Empty lines clear a centred band for readability. func (m model) overlayGrid(grid [][]string, lines []overline) { startY := (len(grid) - len(lines)) / 2 if startY < 0 { startY = 0 } // widest line determines the cleared band maxW := 0 for _, ol := range lines { if n := len([]rune(ol.text)); n > maxW { maxW = n } } for i, ol := range lines { y := startY + i if y < 0 || y >= len(grid) { continue } // clear a band as wide as the widest line so text sits on a clean row bandX := (m.width - maxW - 2) / 2 for j := 0; j < maxW+2; j++ { x := bandX + j if x >= 0 && x < m.width { grid[y][x] = " " } } runes := []rune(ol.text) startX := (m.width - len(runes)) / 2 for j, r := range runes { x := startX + j if x < 0 || x >= m.width { continue } grid[y][x] = ol.style.Render(string(r)) } } } func main() { p := tea.NewProgram(newGame(0, 0, 0, 0), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Println("error running game:", err) } }