Skip to content

Instantly share code, notes, and snippets.

@rbobillot
Last active May 21, 2026 10:54
Show Gist options
  • Select an option

  • Save rbobillot/49ba78556a6f2762bfb5071865abf7a0 to your computer and use it in GitHub Desktop.

Select an option

Save rbobillot/49ba78556a6f2762bfb5071865abf7a0 to your computer and use it in GitHub Desktop.
Scala 2048 TUI using Layoutz
//> using dep xyz.matthieucourt::layoutz:0.7.0
//> using dep io.getkyo::kyo-core:1.0.0-RC2
import layoutz.{Text as UiText, *}
import kyo.*
type Grid = Vector[Vector[Int]]
case class Cell(row: Int, col: Int)
case class TileMotion(from: Cell, to: Cell, value: Int)
enum Direction:
case Up, Down, Left, Right
def slide(grid: Grid): SlideResult =
val normalised = Direction.toSlideLeftView(this, grid)
val result =
if this == Direction.Down then GridEngine.slideRight(normalised)
else GridEngine.slideLeft(normalised)
Direction.fromSlideLeftView(this, result)
private object Direction:
private def toSlideLeftView(direction: Direction, grid: Grid): Grid =
direction match
case Direction.Left => grid
case Direction.Right => grid.map(_.reverse)
case Direction.Up => GridEngine.transpose(grid)
case Direction.Down => GridEngine.transpose(grid)
private def fromSlideLeftView(direction: Direction,
result: SlideResult
): SlideResult =
direction match
case Direction.Left => result
case Direction.Right =>
result
.mapGrid(_.map(_.reverse))
.mapCells(cell => Cell(cell.row, Config.Size - 1 - cell.col))
case Direction.Up | Direction.Down =>
result
.mapGrid(GridEngine.transpose)
.mapCells(cell => Cell(cell.col, cell.row))
case class SlideResult(
grid: Grid,
score: Int,
mergedSources: Map[Cell, Int],
motions: List[TileMotion]
):
def mapGrid(f: Grid => Grid): SlideResult = copy(grid = f(grid))
def mapCells(f: Cell => Cell): SlideResult =
copy(
mergedSources = mergedSources.map { case (cell, source) =>
f(cell) -> source
},
motions = motions.map(motion =>
motion.copy(from = f(motion.from), to = f(motion.to))
)
)
case class GameState(
grid: Grid,
score: Int,
best: Int,
won: Boolean,
gameOver: Boolean,
tick: Int = 0,
spawnCell: Option[Cell] = None,
slideMotions: List[TileMotion] = Nil,
mergedSources: Map[Cell, Int] = Map.empty,
lastGained: Int = 0,
pendingMoves: List[Direction] = Nil
)
enum GameEvent:
case Move(direction: Direction)
case Restart
case Tick
case Quit
private case class Rgb(r: Int, g: Int, b: Int):
def toColor: Color = Color.True(r, g, b)
private case class TileColors(number: Color, tile: Color)
private case class TileLineStyle(
background: Color,
foreground: Color,
text: String,
bold: Boolean
)
private object Theme:
enum NamedColor(val rgb: Rgb):
case DarkText extends NamedColor(Rgb(119, 110, 101))
case LightText extends NamedColor(Rgb(249, 246, 242))
case EmptyCell extends NamedColor(Rgb(205, 193, 180))
case BoardBg extends NamedColor(Rgb(187, 173, 160))
case BoardBorder extends NamedColor(Rgb(143, 130, 118))
case MergeFlash extends NamedColor(Rgb(255, 255, 255))
case Tile2 extends NamedColor(Rgb(238, 228, 218))
case Tile4 extends NamedColor(Rgb(237, 224, 200))
case Tile8 extends NamedColor(Rgb(242, 177, 121))
case Tile16 extends NamedColor(Rgb(245, 149, 99))
case Tile32 extends NamedColor(Rgb(246, 124, 95))
case Tile64 extends NamedColor(Rgb(246, 94, 59))
case Tile128 extends NamedColor(Rgb(237, 207, 114))
case Tile256 extends NamedColor(Rgb(237, 204, 97))
case Tile512 extends NamedColor(Rgb(237, 200, 80))
case Tile1024 extends NamedColor(Rgb(237, 197, 63))
case Tile2048 extends NamedColor(Rgb(237, 194, 46))
case TileBeyond extends NamedColor(Rgb(60, 58, 50))
def toColor: Color = rgb.toColor
enum Tile(val tileValue: Int, numberColor: NamedColor, tileColor: NamedColor):
case `0` extends Tile(0, NamedColor.DarkText, NamedColor.EmptyCell)
case `2` extends Tile(2, NamedColor.DarkText, NamedColor.Tile2)
case `4` extends Tile(4, NamedColor.DarkText, NamedColor.Tile4)
case `8` extends Tile(8, NamedColor.LightText, NamedColor.Tile8)
case `16` extends Tile(16, NamedColor.LightText, NamedColor.Tile16)
case `32` extends Tile(32, NamedColor.LightText, NamedColor.Tile32)
case `64` extends Tile(64, NamedColor.LightText, NamedColor.Tile64)
case `128` extends Tile(128, NamedColor.LightText, NamedColor.Tile128)
case `256` extends Tile(256, NamedColor.LightText, NamedColor.Tile256)
case `512` extends Tile(512, NamedColor.LightText, NamedColor.Tile512)
case `1024` extends Tile(1024, NamedColor.LightText, NamedColor.Tile1024)
case `2048` extends Tile(2048, NamedColor.LightText, NamedColor.Tile2048)
def colors: TileColors =
TileColors(numberColor.toColor, tileColor.toColor)
object Tile:
private val byValue: Map[Int, Tile] =
values.map(tile => tile.tileValue -> tile).toMap
private val beyondColors: TileColors =
TileColors(NamedColor.LightText.toColor, NamedColor.TileBeyond.toColor)
def forValue(value: Int): TileColors =
byValue.get(value).fold(beyondColors)(_.colors)
/** Runs Kyo effects from Layoutz's synchronous update/init hooks. */
private object KyoFx:
import AllowUnsafe.embrace.danger
def io[A](computation: A < Sync): A =
Sync.Unsafe.evalOrThrow(computation)
def spawn(computation: Unit < Sync): Unit =
KyoApp.Unsafe
.runAndBlock(1.second)(Fiber.initUnscoped(computation).map(_ => ()))
.getOrThrow
def logEventAsync(message: String): Unit =
spawn(Console.printLineErr(message).map(_ => ()))
private object Config:
val Size = 4
val CellW = 6
val CellH = 3
val GapW = 1
val GapV = 1
val BoardPad = 1
val BoardW = BoardPad + Size * CellW + (Size - 1) * GapW + BoardPad
val AnimPulsePeriod = 6
val SlideAnimFrames = 10
val SpawnAnimFrames = 10
val MergeFlashFrames = 8
val ScoreFlashFrames = 10
val AnimFrames = SlideAnimFrames + math.max(SpawnAnimFrames, MergeFlashFrames)
val TickMs = 10
val WinTile = 2048
val InitialSpawns = 2
val SpawnTwoWeight = 9
val SpawnRollSize = 10
val EmptyValue = 0
val BoardBg = Theme.NamedColor.BoardBg.toColor
val BoardBorder = Theme.NamedColor.BoardBorder.toColor
val MergeFlash = Theme.NamedColor.MergeFlash.toColor
private object GridEngine:
import Config.*
private val NeighborOffsets = List(Cell(0, 1), Cell(1, 0))
def empty: Grid =
Vector.fill(Size)(Vector.fill(Size)(EmptyValue))
def withCell(grid: Grid, cell: Cell, value: Int): Grid =
setCell(grid, cell, value)
def freshGrid: Grid < Sync =
Loop(InitialSpawns, empty) { case (remaining, grid) =>
if remaining <= 0 then Loop.done(grid)
else
addRandomTile(grid).map { case (next, _) =>
Loop.continue(remaining - 1, next)
}
}
def addRandomTile(grid: Grid): (Grid, Option[Cell]) < Sync =
freeCells(grid) match
case Nil => (grid, None)
case cells =>
for
idx <- Random.nextInt(cells.size)
roll <- Random.nextInt(SpawnRollSize)
yield
val cell = cells(idx)
val value = if roll < SpawnTwoWeight then 2 else 4
(setCell(grid, cell, value), Some(cell))
def canMove(grid: Grid): Boolean =
freeCells(grid).nonEmpty || hasMergeableNeighbor(grid)
def slideLeft(grid: Grid): SlideResult =
val rowResults = grid.zipWithIndex.map { case (row, rowIndex) =>
mergeRowLeft(row, rowIndex)
}
SlideResult(
grid = rowResults.map(_.row),
score = rowResults.map(_.score).sum,
mergedSources = rowResults.map(_.mergedSources).reduce(_ ++ _),
motions = rowResults.flatMap(_.motions).toList
)
def slideRight(grid: Grid): SlideResult =
slideLeft(grid.map(_.reverse))
.mapGrid(_.map(_.reverse))
.mapCells(cell => Cell(cell.row, Size - 1 - cell.col))
def transpose(grid: Grid): Grid =
Vector.tabulate(Size, Size)((row, col) => grid(col)(row))
private case class RowMergeResult(
row: Vector[Int],
score: Int,
mergedSources: Map[Cell, Int],
motions: List[TileMotion]
)
private def mergeRowLeft(row: Vector[Int], rowIndex: Int): RowMergeResult =
val sources = row.zipWithIndex.collect {
case (value, col) if value != EmptyValue => (col, value)
}.toList
val (mergedRow, score, mergesByCol, motions) = mergeTiles(sources, rowIndex)
RowMergeResult(
row = mergedRow,
score = score,
mergedSources = mergesByCol.map { case (col, source) =>
Cell(rowIndex, col) -> source
},
motions = motions
)
private def setCell(grid: Grid, cell: Cell, value: Int): Grid =
grid.updated(cell.row, grid(cell.row).updated(cell.col, value))
private def freeCells(grid: Grid): List[Cell] =
for
row <- grid.indices.toList
col <- grid(row).indices
if grid(row)(col) == EmptyValue
yield Cell(row, col)
private def hasMergeableNeighbor(grid: Grid): Boolean =
grid.indices.exists: row =>
grid(row).indices.exists: col =>
NeighborOffsets.exists: offset =>
val neighbor = Cell(row + offset.row, col + offset.col)
inBounds(neighbor) && grid(row)(col) == grid(neighbor.row)(
neighbor.col
)
private def inBounds(cell: Cell): Boolean =
cell.row >= 0 && cell.row < Size && cell.col >= 0 && cell.col < Size
private case class MergeAcc(
rowIndex: Int,
remaining: List[(Int, Int)],
merged: List[(Int, Int)],
score: Int,
mergedSources: Map[Int, Int],
motions: List[TileMotion]
):
private def destCol: Int = merged.length
private def dest: Cell = Cell(rowIndex, destCol)
def toResult: (Vector[Int], Int, Map[Int, Int], List[TileMotion]) =
(
Vector.from(merged.reverse.map(_._2)).padTo(Size, EmptyValue),
score,
mergedSources,
motions.reverse
)
def slide(fromCol: Int, value: Int, rest: List[(Int, Int)]): MergeAcc =
copy(
remaining = rest,
merged = (destCol, value) :: merged,
motions = TileMotion(Cell(rowIndex, fromCol), dest, value) :: motions
)
def mergePair(
fromCol: Int,
value: Int,
nextCol: Int,
nextValue: Int,
rest: List[(Int, Int)]
): MergeAcc =
copy(
remaining = rest,
merged = (destCol, value * 2) :: merged,
score = score + value * 2,
mergedSources = mergedSources + (destCol -> value),
motions = TileMotion(Cell(rowIndex, fromCol), dest, value) ::
TileMotion(Cell(rowIndex, nextCol), dest, nextValue) ::
motions
)
private def mergeTiles(
sources: List[(Int, Int)],
rowIndex: Int
): (Vector[Int], Int, Map[Int, Int], List[TileMotion]) =
Loop(MergeAcc(rowIndex, sources, Nil, 0, Map.empty, Nil)) { acc =>
acc.remaining match
case Nil =>
Loop.done(acc.toResult)
case (fromCol, value) :: (nextCol, nextValue) :: rest if value == nextValue =>
Loop.continue(acc.mergePair(fromCol, value, nextCol, nextValue, rest))
case (fromCol, value) :: rest =>
Loop.continue(acc.slide(fromCol, value, rest))
}.eval
private object Input:
private def keyChar(key: Key, lower: Char, upper: Char): Boolean =
key == Key.Char(lower) || key == Key.Char(upper)
val eventFor: PartialFunction[Key, GameEvent] =
case Key.Up => GameEvent.Move(Direction.Up)
case Key.Down => GameEvent.Move(Direction.Down)
case Key.Left => GameEvent.Move(Direction.Left)
case Key.Right => GameEvent.Move(Direction.Right)
case key if keyChar(key, 'r', 'R') => GameEvent.Restart
case key if keyChar(key, 'q', 'Q') => GameEvent.Quit
case Key.Escape => GameEvent.Quit
private object TextLayout:
def center(text: String, width: Int): String =
if text.length >= width then text.take(width)
else
val padding = width - text.length
val left = padding / 2
s"${" " * left}$text${" " * (padding - left)}"
def interleave[A](separator: A, items: Seq[A]): Seq[A] =
items.flatMap(item => Seq(separator, item)).drop(1)
def surround[A](leading: A, items: Seq[A], trailing: A): Seq[A] =
leading +: items :+ trailing
private object BoardRenderer:
import Config.*
import Theme.{NamedColor, Tile}
import TextLayout.{center, interleave, surround}
private def wrapAnsi(color: Color, text: String): String =
if color.code.isEmpty then text
else s"\u001b[${color.code}m$text\u001b[0m"
private def bgSpan(text: String,
width: Int,
background: Color = BoardBg
): Element =
center(text, width).bg(background)
private def repeatLines(count: Int, line: Element): Element =
Layout(List.fill(count)(line))
private def styledLine(text: String, style: TileLineStyle): Element =
val base = center(text, CellW).bg(style.background).color(style.foreground)
if style.bold then base.style(Style.Bold) else base
private def tileBlock(style: TileLineStyle): Element =
val blankLine = styledLine("", style.copy(text = ""))
val valueLine = styledLine(style.text, style)
Layout(List.fill(CellH)(blankLine).updated(CellH / 2, valueLine))
private def tileLineStyle(value: Int,
position: Cell,
state: GameState
): TileLineStyle =
val tile = Tile.forValue(value)
val label = if value == EmptyValue then "" else value.toString
val postAge = state.tick - SlideAnimFrames
val animating = state.slideMotions.nonEmpty && state.tick < SlideAnimFrames
if !animating &&
state.spawnCell.contains(position) &&
postAge >= 0 &&
postAge < SpawnAnimFrames
then
val growChar =
SpinnerStyle.Grow.frames(postAge % SpinnerStyle.Grow.frames.length)
val pulseBg =
if postAge % AnimPulsePeriod < AnimPulsePeriod / 2 then tile.tile
else MergeFlash
TileLineStyle(
background = pulseBg,
foreground = tile.number,
text = if value == EmptyValue then growChar else label,
bold = true
)
else if !animating &&
state.mergedSources
.get(position)
.exists(_ => postAge >= 0 && postAge < MergeFlashFrames)
then
val sourceValue = state.mergedSources(position)
val sourceTile = Tile.forValue(sourceValue)
val flashTile =
if postAge % AnimPulsePeriod < AnimPulsePeriod / 2 then sourceTile
else tile
TileLineStyle(flashTile.tile, flashTile.number, label, bold = true)
else
TileLineStyle(tile.tile, tile.number, label, bold = value != EmptyValue)
private def paddedRow(items: Seq[Element]): Element =
tightRow(surround(sidePad, items, sidePad)*)
private def sidePad: Element =
repeatLines(CellH, bgSpan("", BoardPad))
private def horizontalGap: Element =
repeatLines(CellH, bgSpan("", GapW))
private def verticalGap: Element =
repeatLines(GapV, bgSpan("", BoardW))
private def boardFrame(content: Element): Element = new Element:
def render: String =
val lines = content.render.split('\n').toList
val width = lines.map(realLength).maxOption.getOrElse(BoardW)
val border = "" * width
val body = lines.map: line =>
val padding = width - realLength(line)
s"$line${" " * padding}"
wrapAnsi(BoardBorder, s"$border\n${body.mkString("\n")}\n$border")
private def motionCell(motion: TileMotion, progress: Double): Cell =
Cell(
(motion.from.row + (motion.to.row - motion.from.row) * progress).round.toInt,
(motion.from.col + (motion.to.col - motion.from.col) * progress).round.toInt
)
private def slidingGrid(state: GameState): Grid =
val progress = (state.tick + 1).toDouble / SlideAnimFrames
state.slideMotions.foldLeft(GridEngine.empty) { (grid, motion) =>
GridEngine.withCell(grid, motionCell(motion, progress), motion.value)
}
private def displayGrid(state: GameState): Grid =
if state.slideMotions.nonEmpty && state.tick < SlideAnimFrames then
slidingGrid(state)
else state.grid
private def tileElement(value: Int,
position: Cell,
state: GameState
): Element =
tileBlock(tileLineStyle(value, position, state))
def board(state: GameState): Element =
val values = displayGrid(state)
val rows = values.zipWithIndex.map { case (gridRow, rowIndex) =>
val tiles = gridRow.zipWithIndex.map { case (value, colIndex) =>
tileElement(value, Cell(rowIndex, colIndex), state)
}
paddedRow(interleave(horizontalGap, tiles))
}
val layoutGrid = Layout(
List.fill(BoardPad)(verticalGap) ++
interleave(verticalGap, rows) ++
List.fill(BoardPad)(verticalGap)
)
boardFrame(layoutGrid)
private def statPanel(label: String, value: Element): Element =
statusCard(
label.color(Color.BrightBlack).style(Style.Bold),
value
).border(Border.Round).color(BoardBg)
private def highlightedScore(value: Int, active: Boolean): Element =
val color = if active then Color.BrightYellow else Color.BrightWhite
value.toString.color(color).style(Style.Bold)
def sidebar(state: GameState): Element =
val postAge = state.tick - SlideAnimFrames
val scoreActive =
state.lastGained > 0 && postAge >= 0 && postAge < ScoreFlashFrames
val gainFlash =
if scoreActive then
s"+${state.lastGained}".color(Color.BrightGreen).style(Style.Bold)
else UiText("")
layout(
statPanel("SCORE", highlightedScore(state.score, scoreActive)),
br,
statPanel("BEST", highlightedScore(state.best, active = false)),
br,
gainFlash
)
def statusLine(state: GameState): Element =
if state.gameOver then
row(
spinner("Game over", state.tick / 2, SpinnerStyle.Moon)
.color(Color.BrightRed),
" Press R to restart.".color(Color.BrightRed).style(Style.Bold)
)
else if state.won then
"You reached 2048! Keep going or press R."
.color(Color.BrightYellow)
.style(Style.Bold)
else "Slide the tiles to reach 2048!".color(Color.BrightBlack)
def render(state: GameState): Element =
layout(
banner("2048".style(Style.Bold).color(Color.BrightYellow))
.border(Border.Round),
br,
columns(board(state), sidebar(state)),
br,
box("Status")(statusLine(state))
.border(Border.Round)
.color(Color.BrightCyan),
br,
section("Controls")(
ul(
"Arrow Keys — slide tiles",
"R — new game",
"Q or Esc — quit"
)
)
)
object Game2048 extends LayoutzApp[GameState, GameEvent]:
import Config.*
private def freshState(grid: Grid): GameState =
GameState(grid = grid, score = 0, best = 0, won = false, gameOver = false)
private def notifyTransitions(before: GameState, after: GameState): Unit =
if !before.won && after.won then
KyoFx.logEventAsync(s"[2048] You reached $WinTile! Score: ${after.score}")
if !before.gameOver && after.gameOver then
KyoFx.logEventAsync(s"[2048] Game over. Final score: ${after.score}")
if after.lastGained >= 128 then
KyoFx.logEventAsync(s"[2048] +${after.lastGained} (total ${after.score})")
private def withNotifications(before: GameState,
after: GameState
): GameState =
notifyTransitions(before, after)
after
private def clearAnimation(state: GameState): GameState =
state.copy(
spawnCell = None,
slideMotions = Nil,
mergedSources = Map.empty,
lastGained = 0
)
private def isAnimating(state: GameState): Boolean =
state.slideMotions.nonEmpty && state.tick < AnimFrames
private def enqueueMove(state: GameState, direction: Direction): GameState =
state.copy(pendingMoves = state.pendingMoves :+ direction)
private def drainMoves(state: GameState): GameState =
state.pendingMoves match
case direction :: rest =>
val next = applyMove(state.copy(pendingMoves = rest), direction)
if next.slideMotions.nonEmpty then next
else drainMoves(next)
case Nil => state
private def handleMove(state: GameState, direction: Direction): GameState =
if isAnimating(state) then enqueueMove(state, direction)
else applyMove(state, direction)
private def hasWon(grid: Grid): Boolean =
grid.exists(_.exists(_ >= WinTile))
private def applyMove(state: GameState, direction: Direction): GameState =
val result = direction.slide(state.grid)
if result.grid == state.grid then state
else
val (grid, spawn) = KyoFx.io(GridEngine.addRandomTile(result.grid))
val score = state.score + result.score
withNotifications(
state,
state.copy(
grid = grid,
score = score,
best = math.max(state.best, score),
won = state.won || hasWon(grid),
gameOver = !GridEngine.canMove(grid),
tick = 0,
spawnCell = spawn,
slideMotions = result.motions,
mergedSources = result.mergedSources,
lastGained = result.score
)
)
private def onTick(state: GameState): GameState =
if state.slideMotions.isEmpty then
if state.pendingMoves.nonEmpty then drainMoves(state)
else state
else
val nextTick = state.tick + 1
if nextTick > AnimFrames then
drainMoves(clearAnimation(state.copy(tick = 0)))
else state.copy(tick = nextTick)
def init: (GameState, Cmd[GameEvent]) =
(freshState(KyoFx.io(GridEngine.freshGrid)), Cmd.setTitle("2048"))
def update(event: GameEvent, state: GameState): (GameState, Cmd[GameEvent]) =
event match
case GameEvent.Quit =>
KyoFx.logEventAsync(s"[2048] Quit — score ${state.score}, best ${state.best}")
(state, Cmd.exit)
case GameEvent.Tick => (onTick(state), Cmd.none)
case GameEvent.Restart =>
KyoFx.logEventAsync("[2048] New game")
(freshState(KyoFx.io(GridEngine.freshGrid)), Cmd.none)
case GameEvent.Move(direction) if state.gameOver => (state, Cmd.none)
case GameEvent.Move(direction) =>
(handleMove(state, direction), Cmd.none)
def subscriptions(state: GameState): Sub[GameEvent] =
Sub.batch(
Sub.time.everyMs(TickMs, GameEvent.Tick),
Sub.onKeyPress(Input.eventFor.lift)
)
def view(state: GameState): Element = BoardRenderer.render(state)
@main def go =
import AllowUnsafe.embrace.danger
Sync.Unsafe.evalOrThrow:
for
_ <- Console.printLineErr(
"[2048] Kyo effects on — RNG, async stderr events. Arrow keys to play."
)
_ <- Sync.defer:
run(
quitKey = Key.Unknown(-1),
showQuitMessage = false
)
yield ()
@rbobillot

Copy link
Copy Markdown
Author
Screenshot 2026-05-21 at 12 24 20

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment