back to list

Playable Example

PacMan

pacman.lua ยท 800 x 620

runtime 800 x 620
Preparing Runtime

Loading game...

console stdout / stderr
 
pacman.game launch config
entry : pacman.lua
width : 800
height : 620
window_title : "PacMan"
background : 0xff000000
pacman.lua source
local soluna = require "soluna"
local app = require "soluna.app"
local file = require "soluna.file"
local matquad = require "soluna.material.quad"
local mattext = require "soluna.material.text"
local font = require "soluna.font"
local flow = require "flow"
local util = require "utils"

math.randomseed(os.time())

local args = ...
local batch = args.batch

soluna.load_sounds "asset/pacman/sounds.dl"

local DISPLAY_TILES_X = 28
local DISPLAY_TILES_Y = 36
local BOARD_TILE_Y0 = 3
local BOARD_TILE_Y1 = 33
local BOARD_TILES_Y = BOARD_TILE_Y1 - BOARD_TILE_Y0 + 1

local TILE = 8
local SPRITE = 16
local SCALE = 2
local DRAW_TILE = TILE * SCALE
local DRAW_SPRITE = SPRITE * SCALE

local MAP_X = 24
local MAP_Y = 32
local BOARD_W = DISPLAY_TILES_X * DRAW_TILE
local BOARD_H = BOARD_TILES_Y * DRAW_TILE
local PANEL_X = MAP_X + BOARD_W + 24

local W = 760
local H = 560

local KEY_ESCAPE = 256
local KEY_LEFT = 263
local KEY_RIGHT = 262
local KEY_UP = 265
local KEY_DOWN = 264
local KEY_A = 65
local KEY_D = 68
local KEY_P = 80
local KEY_R = 82
local KEY_S = 83
local KEY_W = 87
local KEYSTATE_PRESS = 1

local DIR_RIGHT = 0
local DIR_DOWN = 1
local DIR_LEFT = 2
local DIR_UP = 3

local GHOST_BLINKY = 1
local GHOST_PINKY = 2
local GHOST_INKY = 3
local GHOST_CLYDE = 4

local GHOSTSTATE_CHASE = 1
local GHOSTSTATE_SCATTER = 2
local GHOSTSTATE_FRIGHTENED = 3
local GHOSTSTATE_EYES = 4
local GHOSTSTATE_HOUSE = 5
local GHOSTSTATE_LEAVEHOUSE = 6
local GHOSTSTATE_ENTERHOUSE = 7

local FREEZETYPE_READY = 1 << 0
local FREEZETYPE_EAT_GHOST = 1 << 1
local FREEZETYPE_DEAD = 1 << 2
local FREEZETYPE_WON = 1 << 3

local DISABLED_TICKS = -1

local NUM_LIVES = 3
local NUM_DOTS = 244
local ANTEPORTAS_X = 14 * TILE
local ANTEPORTAS_Y = 14 * TILE + TILE // 2
local GHOST_EATEN_FREEZE_TICKS = 60
local PACMAN_EATEN_TICKS = 60
local PACMAN_DEATH_TICKS = 150
local ROUNDWON_TICKS = 4 * 60
local FRUITACTIVE_TICKS = 10 * 60
local READY_TICKS = 2 * 60 + 10
local FORCE_LEAVE_HOUSE_TICKS = 4 * 60
local FRIGHT_TICKS = 6 * 60

local SCORE_DOT = 10
local SCORE_PILL = 50
local SCORE_FRUIT = 100

local COLOR_BLACK = 0xff000000
local COLOR_WHITE = 0xffffffff
local COLOR_PANEL = 0xff101726
local COLOR_PANEL_LINE = 0xff34435a
local COLOR_GREEN = 0xff58d668
local COLOR_YELLOW = 0xffffdf63
local COLOR_GRAY = 0xff9aa6bf
local COLOR_RED = 0xffff6666

local PLAYFIELD_ROWS = {
	"0UUUUUUUUUUUU45UUUUUUUUUUUU1",
	"L............rl............R",
	"L.ebbf.ebbbf.rl.ebbbf.ebbf.R",
	"LPr  l.r   l.rl.r   l.r  lPR",
	"L.guuh.guuuh.gh.guuuh.guuh.R",
	"L..........................R",
	"L.ebbf.ef.ebbbbbbf.ef.ebbf.R",
	"L.guuh.rl.guuyxuuh.rl.guuh.R",
	"L......rl....rl....rl......R",
	"2BBBBf.rzbbf rl ebbwl.eBBBB3",
	"     L.rxuuh gh guuyl.R     ",
	"     L.rl          rl.R     ",
	"     L.rl mjs--tjn rl.R     ",
	"UUUUUh.gh i      q gh.gUUUUU",
	"      .   i      q   .      ",
	"BBBBBf.ef i      q ef.eBBBBB",
	"     L.rl okkkkkkp rl.R     ",
	"     L.rl          rl.R     ",
	"     L.rl ebbbbbbf rl.R     ",
	"0UUUUh.gh guuyxuuh gh.gUUUU1",
	"L............rl............R",
	"L.ebbf.ebbbf.rl.ebbbf.ebbf.R",
	"L.guyl.guuuh.gh.guuuh.rxuh.R",
	"LP..rl.......  .......rl..PR",
	"6bf.rl.ef.ebbbbbbf.ef.rl.eb8",
	"7uh.gh.rl.guuyxuuh.rl.gh.gu9",
	"L......rl....rl....rl......R",
	"L.ebbbbwzbbf.rl.ebbwzbbbbf.R",
	"L.guuuuuuuuh.gh.guuuuuuuuh.R",
	"L..........................R",
	"2BBBBBBBBBBBBBBBBBBBBBBBBBB3",
}

local PLAYFIELD_TILE_CODE = {
	["0"] = "D1",
	["1"] = "D0",
	["2"] = "D5",
	["3"] = "D4",
	["4"] = "FB",
	["5"] = "FA",
	["6"] = "D7",
	["7"] = "D9",
	["8"] = "D6",
	["9"] = "D8",
	U = "DB",
	L = "D3",
	R = "D2",
	B = "DC",
	b = "DF",
	e = "E7",
	f = "E6",
	g = "EB",
	h = "EA",
	l = "E8",
	r = "E9",
	u = "E5",
	w = "F5",
	x = "F2",
	y = "F3",
	z = "F4",
	m = "ED",
	n = "EC",
	o = "EF",
	p = "EE",
	j = "DD",
	i = "D2",
	k = "DB",
	q = "D3",
	s = "F1",
	t = "F0",
	["-"] = "CF",
}

local GHOST_SCATTER_TARGETS = {
	[GHOST_BLINKY] = { x = 25, y = 0 },
	[GHOST_PINKY] = { x = 2, y = 0 },
	[GHOST_INKY] = { x = 27, y = 34 },
	[GHOST_CLYDE] = { x = 0, y = 34 },
}

local GHOST_STARTING_POS = {
	[GHOST_BLINKY] = { x = 14 * TILE, y = 14 * TILE + TILE // 2 },
	[GHOST_PINKY] = { x = 14 * TILE, y = 17 * TILE + TILE // 2 },
	[GHOST_INKY] = { x = 12 * TILE, y = 17 * TILE + TILE // 2 },
	[GHOST_CLYDE] = { x = 16 * TILE, y = 17 * TILE + TILE // 2 },
}

local GHOST_HOUSE_TARGET_POS = {
	[GHOST_BLINKY] = { x = 14 * TILE, y = 17 * TILE + TILE // 2 },
	[GHOST_PINKY] = { x = 14 * TILE, y = 17 * TILE + TILE // 2 },
	[GHOST_INKY] = { x = 12 * TILE, y = 17 * TILE + TILE // 2 },
	[GHOST_CLYDE] = { x = 16 * TILE, y = 17 * TILE + TILE // 2 },
}

local GHOST_INIT_DIR = {
	[GHOST_BLINKY] = DIR_LEFT,
	[GHOST_PINKY] = DIR_DOWN,
	[GHOST_INKY] = DIR_UP,
	[GHOST_CLYDE] = DIR_UP,
}

local GHOST_DOT_LIMIT = {
	[GHOST_BLINKY] = 0,
	[GHOST_PINKY] = 0,
	[GHOST_INKY] = 30,
	[GHOST_CLYDE] = 60,
}

local GHOST_SCORE_TEXT = {
	[200] = "200",
	[400] = "400",
	[800] = "800",
	[1600] = "1600",
}

local LEVELSPEC = {
	fruit_name = "Cherries",
	bonus_score = SCORE_FRUIT,
	fright_ticks = FRIGHT_TICKS,
}

local function trigger()
	return { tick = DISABLED_TICKS }
end

local function disable(t)
	t.tick = DISABLED_TICKS
end

local function start(t, now_tick)
	t.tick = now_tick
end

local function start_after(t, now_tick, delay)
	t.tick = now_tick + delay
end

local function since(t, now_tick)
	if t.tick == DISABLED_TICKS then
		return DISABLED_TICKS
	end
	return now_tick - t.tick
end

local function now(t, now_tick)
	return t.tick == now_tick
end

local function before(t, now_tick, ticks)
	local s = since(t, now_tick)
	return s ~= DISABLED_TICKS and s < ticks
end

local function after(t, now_tick, ticks)
	local s = since(t, now_tick)
	return s ~= DISABLED_TICKS and s >= ticks
end

local function after_once(t, now_tick, ticks)
	return since(t, now_tick) == ticks
end

local function clamp(v, min_v, max_v)
	if v < min_v then
		return min_v
	end
	if v > max_v then
		return max_v
	end
	return v
end

local function vec(x, y)
	return { x = x, y = y }
end

local function add_i2(a, b)
	return { x = a.x + b.x, y = a.y + b.y }
end

local function sub_i2(a, b)
	return { x = a.x - b.x, y = a.y - b.y }
end

local function mul_i2(a, s)
	return { x = a.x * s, y = a.y * s }
end

local function equal_i2(a, b)
	return a.x == b.x and a.y == b.y
end

local function nearequal_i2(a, b, tolerance)
	return math.abs(a.x - b.x) <= tolerance and math.abs(a.y - b.y) <= tolerance
end

local function squared_distance_i2(a, b)
	local dx = b.x - a.x
	local dy = b.y - a.y
	return dx * dx + dy * dy
end

local function dir_to_vec(dir)
	if dir == DIR_RIGHT then
		return vec(1, 0)
	elseif dir == DIR_DOWN then
		return vec(0, 1)
	elseif dir == DIR_LEFT then
		return vec(-1, 0)
	else
		return vec(0, -1)
	end
end

local function reverse_dir(dir)
	if dir == DIR_RIGHT then
		return DIR_LEFT
	elseif dir == DIR_DOWN then
		return DIR_UP
	elseif dir == DIR_LEFT then
		return DIR_RIGHT
	else
		return DIR_DOWN
	end
end

local function pixel_to_tile_pos(pos)
	return { x = pos.x // TILE, y = pos.y // TILE }
end

local function clamped_tile_pos(tile_pos)
	local out = { x = tile_pos.x, y = tile_pos.y }
	if out.x < 0 then
		out.x = 0
	elseif out.x >= DISPLAY_TILES_X then
		out.x = DISPLAY_TILES_X - 1
	end
	if out.y < BOARD_TILE_Y0 then
		out.y = BOARD_TILE_Y0
	elseif out.y > BOARD_TILE_Y1 then
		out.y = BOARD_TILE_Y1
	end
	return out
end

local function dist_to_tile_mid(pos)
	return {
		x = TILE // 2 - pos.x % TILE,
		y = TILE // 2 - pos.y % TILE,
	}
end

local function screen_from_actor_pos(pos)
	return MAP_X + pos.x * SCALE, MAP_Y + (pos.y - BOARD_TILE_Y0 * TILE) * SCALE
end

local function screen_from_tile_pos(tile_pos)
	return MAP_X + tile_pos.x * DRAW_TILE, MAP_Y + (tile_pos.y - BOARD_TILE_Y0) * DRAW_TILE
end

local wall_chars = {}
for _, ch in ipairs {
	"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
	"U", "L", "R", "B", "b", "e", "f", "g", "h", "l", "r", "u",
	"w", "x", "y", "z", "m", "n", "o", "p", "j", "i", "k", "q", "s", "t",
} do
	wall_chars[ch] = true
end

local board_template = {}

for row_index, line in ipairs(PLAYFIELD_ROWS) do
	local tile_y = BOARD_TILE_Y0 + row_index - 1
	board_template[tile_y] = {}
	for x = 0, DISPLAY_TILES_X - 1 do
		local ch = line:sub(x + 1, x + 1)
		local cell = {
			char = ch,
			tile_code = PLAYFIELD_TILE_CODE[ch],
			wall = wall_chars[ch] or false,
			door = ch == "-",
			dot = ch == ".",
			pill = ch == "P",
		}
		board_template[tile_y][x] = cell
	end
end

local function copy_board()
	local out = {}
	for y = BOARD_TILE_Y0, BOARD_TILE_Y1 do
		out[y] = {}
		for x = 0, DISPLAY_TILES_X - 1 do
			local src = board_template[y][x]
			out[y][x] = {
				char = src.char,
				tile_code = src.tile_code,
				wall = src.wall,
				door = src.door,
				dot = src.dot,
				pill = src.pill,
			}
		end
	end
	return out
end

local function build_sprite_assets()
	local assets = soluna.load_sprites "asset/pacman/sprites.dl"
	assets.tiles = soluna.load_sprites "asset/pacman/tiles.dl"

	assets.fruit = assets.sprite_00_cherries

	assets.pacman = {}
	assets.pacman_closed = assets.sprite_48_pacman
	assets.pacman[DIR_RIGHT] = {
		assets.sprite_44_pacman,
		assets.sprite_46_pacman,
		assets.sprite_48_pacman,
		assets.sprite_46_pacman,
	}
	assets.pacman[DIR_DOWN] = {
		assets.sprite_45_pacman,
		assets.sprite_47_pacman,
		assets.sprite_48_pacman,
		assets.sprite_47_pacman,
	}
	assets.pacman[DIR_LEFT] = {
		assets.sprite_44_pacman_left,
		assets.sprite_46_pacman_left,
		assets.sprite_48_pacman,
		assets.sprite_46_pacman_left,
	}
	assets.pacman[DIR_UP] = {
		assets.sprite_45_pacman_up,
		assets.sprite_47_pacman_up,
		assets.sprite_48_pacman,
		assets.sprite_47_pacman_up,
	}

	assets.pacman_death = {}
	for tile = 52, 62 do
		assets.pacman_death[#assets.pacman_death + 1] = assets["sprite_" .. tile .. "_pacman"]
	end

	assets.ghost = {}
	assets.ghost_eyes = {}
	local ghost_names = {
		[GHOST_BLINKY] = "blinky",
		[GHOST_PINKY] = "pinky",
		[GHOST_INKY] = "inky",
		[GHOST_CLYDE] = "clyde",
	}
	local ghost_tiles = {
		[DIR_RIGHT] = { 32, 33 },
		[DIR_DOWN] = { 34, 35 },
		[DIR_LEFT] = { 36, 37 },
		[DIR_UP] = { 38, 39 },
	}
	for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
		assets.ghost[ghost_type] = {}
		for _, dir in ipairs { DIR_RIGHT, DIR_DOWN, DIR_LEFT, DIR_UP } do
			assets.ghost[ghost_type][dir] = {
				assets["sprite_" .. ghost_tiles[dir][1] .. "_" .. ghost_names[ghost_type]],
				assets["sprite_" .. ghost_tiles[dir][2] .. "_" .. ghost_names[ghost_type]],
			}
		end
	end

	for _, dir in ipairs { DIR_RIGHT, DIR_DOWN, DIR_LEFT, DIR_UP } do
		assets.ghost_eyes[dir] = assets["sprite_" .. ghost_tiles[dir][1] .. "_eyes"]
	end

	assets.frightened = { assets.sprite_28_frightened, assets.sprite_29_frightened }
	assets.frightened_flash = { assets.sprite_28_frightened_blink, assets.sprite_29_frightened_blink }
	assets.ghost_score = {
		[200] = assets.sprite_40_ghost_score,
		[400] = assets.sprite_41_ghost_score,
		[800] = assets.sprite_42_ghost_score,
		[1600] = assets.sprite_43_ghost_score,
	}

	return assets
end

local sprites = build_sprite_assets()

soluna.set_window_title "PacMan"

local fontid = util.font_init(soluna, font, file, {
	error_message = "No available system font for pacman",
})
local fontcobj = font.cobj()

local title_block = mattext.block(fontcobj, fontid, 28, COLOR_YELLOW, "LT")
local label_block = mattext.block(fontcobj, fontid, 16, COLOR_WHITE, "LT")
local value_block = mattext.block(fontcobj, fontid, 18, COLOR_YELLOW, "LT")
local hint_block = mattext.block(fontcobj, fontid, 14, COLOR_GRAY, "LT")
local ready_block = mattext.block(fontcobj, fontid, 24, COLOR_YELLOW, "CV")
local over_block = mattext.block(fontcobj, fontid, 26, COLOR_RED, "CV")
local win_block = mattext.block(fontcobj, fontid, 26, COLOR_GREEN, "CV")
local ghost_score_block = mattext.block(fontcobj, fontid, 16, COLOR_WHITE, "LT")
local fruit_score_block = mattext.block(fontcobj, fontid, 16, COLOR_YELLOW, "LT")

local label = util.label_cache()
local quad = util.quad_cache(matquad)
local view = util.fixed_view(args, W, H)

local input = {
	enabled = true,
	up = false,
	down = false,
	left = false,
	right = false,
	pause = false,
	restart = false,
}

local key_down = {}

local game = {
	tick = 0,
	board = copy_board(),
	score = 0,
	hiscore = 0,
	round = 0,
	num_lives = NUM_LIVES,
	freeze = 0,
	num_dots_eaten = 0,
	num_ghosts_eaten = 0,
	global_dot_counter_active = false,
	global_dot_counter = 0,
	xorshift = 0x12345678,
	pacman = {
		actor = {
			dir = DIR_LEFT,
			pos = { x = 14 * TILE, y = 26 * TILE + TILE // 2 },
			anim_tick = 0,
		},
	},
	ghost = {},
	active_fruit = false,
	fruit_score_value = nil,
	fruit_score_pos = nil,
	ready_started = trigger(),
	round_started = trigger(),
	round_won = trigger(),
	game_over_trigger = trigger(),
	dot_eaten = trigger(),
	pill_eaten = trigger(),
	ghost_eaten = trigger(),
	pacman_eaten = trigger(),
	fruit_eaten = trigger(),
	force_leave_house = trigger(),
	fruit_active = trigger(),
}

local audio = {
	ambient = nil,
	dead = nil,
	prelude = nil,
}

local function stop_voice(voice)
	if voice then
		voice:stop()
	end
end

local function stop_ambient()
	stop_voice(audio.ambient)
	audio.ambient = nil
end

local function play_sound(name, opts)
	local voice, err = soluna.play_sound(name, opts)
	if voice == nil then
		error(err or ("failed to play sound " .. tostring(name)))
	end
	return voice
end

local function play_effect(name, opts)
	return play_sound(name, opts)
end

local function play_prelude()
	stop_voice(audio.prelude)
	audio.prelude = play_sound "prelude"
end

local function play_ambient(name)
	stop_ambient()
	audio.ambient = play_sound(name)
end

local function play_dead()
	stop_voice(audio.dead)
	audio.dead = play_sound "dead"
end

local function clear_sounds()
	stop_ambient()
	stop_voice(audio.dead)
	stop_voice(audio.prelude)
	audio.dead = nil
	audio.prelude = nil
end

local function xorshift32()
	local x = game.xorshift
	x = ((x ~ (x << 13)) & 0xffffffff)
	x = ((x ~ (x >> 17)) & 0xffffffff)
	x = ((x ~ (x << 5)) & 0xffffffff)
	game.xorshift = x
	return x
end

local function disable_game_timers()
	disable(game.ready_started)
	disable(game.round_started)
	disable(game.round_won)
	disable(game.game_over_trigger)
	disable(game.dot_eaten)
	disable(game.pill_eaten)
	disable(game.ghost_eaten)
	disable(game.pacman_eaten)
	disable(game.fruit_eaten)
	disable(game.force_leave_house)
	disable(game.fruit_active)
end

local function input_disable()
	input.enabled = false
	input.up = false
	input.down = false
	input.left = false
	input.right = false
	key_down = {}
end

local function input_enable()
	input.enabled = true
	input.up = false
	input.down = false
	input.left = false
	input.right = false
	key_down = {}
end

local function consume_restart()
	if input.restart then
		input.restart = false
		return true
	end
	return false
end

local function consume_pause()
	if input.pause then
		input.pause = false
		return true
	end
	return false
end

local function input_dir(default_dir)
	if not input.enabled then
		return default_dir
	end
	if input.up then
		return DIR_UP
	elseif input.down then
		return DIR_DOWN
	elseif input.right then
		return DIR_RIGHT
	elseif input.left then
		return DIR_LEFT
	end
	return default_dir
end

local function update_move_input()
	input.up = key_down[KEY_UP] or key_down[KEY_W] or false
	input.down = key_down[KEY_DOWN] or key_down[KEY_S] or false
	input.left = key_down[KEY_LEFT] or key_down[KEY_A] or false
	input.right = key_down[KEY_RIGHT] or key_down[KEY_D] or false
end

local function board_cell_at(tile_pos)
	local row = game.board[tile_pos.y]
	return row and row[tile_pos.x] or nil
end

local function is_blocking_tile(tile_pos)
	local cell = board_cell_at(tile_pos)
	return cell == nil or cell.wall or cell.door
end

local function is_dot(tile_pos)
	local cell = board_cell_at(tile_pos)
	return cell ~= nil and cell.dot
end

local function is_pill(tile_pos)
	local cell = board_cell_at(tile_pos)
	return cell ~= nil and cell.pill
end

local function is_tunnel(tile_pos)
	return tile_pos.y == 17 and (tile_pos.x <= 5 or tile_pos.x >= 22)
end

local function is_redzone(tile_pos)
	return tile_pos.x >= 11 and tile_pos.x <= 16 and (tile_pos.y == 14 or tile_pos.y == 26)
end

local function can_move(pos, wanted_dir, allow_cornering)
	local dir_vec = dir_to_vec(wanted_dir)
	local dist_mid = dist_to_tile_mid(pos)
	local move_dist_mid
	local perp_dist_mid

	if dir_vec.y ~= 0 then
		move_dist_mid = dist_mid.y
		perp_dist_mid = dist_mid.x
	else
		move_dist_mid = dist_mid.x
		perp_dist_mid = dist_mid.y
	end

	local tile_pos = pixel_to_tile_pos(pos)
	local check_pos = clamped_tile_pos(add_i2(tile_pos, dir_vec))
	local blocked = is_blocking_tile(check_pos)
	if ((not allow_cornering) and perp_dist_mid ~= 0) or (blocked and move_dist_mid == 0) then
		return false
	end
	return true
end

local function move_pos(pos, dir, allow_cornering)
	local dir_vec = dir_to_vec(dir)
	local next_pos = add_i2(pos, dir_vec)

	if allow_cornering then
		local dist_mid = dist_to_tile_mid(next_pos)
		if dir_vec.x ~= 0 then
			if dist_mid.y < 0 then
				next_pos.y = next_pos.y - 1
			elseif dist_mid.y > 0 then
				next_pos.y = next_pos.y + 1
			end
		else
			if dist_mid.x < 0 then
				next_pos.x = next_pos.x - 1
			elseif dist_mid.x > 0 then
				next_pos.x = next_pos.x + 1
			end
		end
	end

	if next_pos.x < 0 then
		next_pos.x = DISPLAY_TILES_X * TILE - 1
	elseif next_pos.x >= DISPLAY_TILES_X * TILE then
		next_pos.x = 0
	end

	return next_pos
end

local logic = {}

do
	local function make_ghost(ghost_type)
		local pos = GHOST_STARTING_POS[ghost_type]
		local state = GHOSTSTATE_HOUSE
		if ghost_type == GHOST_BLINKY then
			state = GHOSTSTATE_SCATTER
		end
		return {
			type = ghost_type,
			actor = {
				dir = GHOST_INIT_DIR[ghost_type],
				pos = { x = pos.x, y = pos.y },
				anim_tick = 0,
			},
			next_dir = GHOST_INIT_DIR[ghost_type],
			target_pos = { x = 0, y = 0 },
			state = state,
			frightened = trigger(),
			eaten = trigger(),
			dot_counter = 0,
			dot_limit = GHOST_DOT_LIMIT[ghost_type],
			eat_score = 0,
		}
	end

	local function game_round_init()
		if game.num_dots_eaten == NUM_DOTS then
			game.round = game.round + 1
			game.num_dots_eaten = 0
			game.board = copy_board()
			game.global_dot_counter_active = false
		else
			if game.num_lives ~= NUM_LIVES then
				game.global_dot_counter_active = true
				game.global_dot_counter = 0
			end
			game.num_lives = game.num_lives - 1
		end

		game.active_fruit = false
		game.fruit_score_value = nil
		game.fruit_score_pos = nil
		game.freeze = FREEZETYPE_READY
		game.xorshift = 0x12345678
		game.num_ghosts_eaten = 0
		disable_game_timers()
		start(game.force_leave_house, game.tick)

		game.pacman = {
			actor = {
				dir = DIR_LEFT,
				pos = { x = 14 * TILE, y = 26 * TILE + TILE // 2 },
				anim_tick = 0,
			},
		}

		game.ghost = {}
		for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
			game.ghost[ghost_type] = make_ghost(ghost_type)
		end

		input_enable()
	end

	local function game_init()
		clear_sounds()
		play_prelude()
		game.board = copy_board()
		disable_game_timers()
		game.round = 0
		game.freeze = 0
		game.num_lives = NUM_LIVES
		game.global_dot_counter_active = false
		game.global_dot_counter = 0
		game.num_dots_eaten = 0
		game.num_ghosts_eaten = 0
		game.score = 0
		game.active_fruit = false
		game.fruit_score_value = nil
		game.fruit_score_pos = nil
		input_enable()
		game_round_init()
		start_after(game.round_started, game.tick, READY_TICKS)
	end

	local function game_pacman_should_move()
		if now(game.dot_eaten, game.tick) then
			return false
		elseif since(game.pill_eaten, game.tick) ~= DISABLED_TICKS and since(game.pill_eaten, game.tick) < 3 then
			return false
		end
		return game.tick % 8 ~= 0
	end

	local function game_ghost_speed(ghost)
		if ghost.state == GHOSTSTATE_HOUSE or ghost.state == GHOSTSTATE_LEAVEHOUSE then
			return game.tick & 1
		elseif ghost.state == GHOSTSTATE_FRIGHTENED then
			return game.tick & 1
		elseif ghost.state == GHOSTSTATE_EYES or ghost.state == GHOSTSTATE_ENTERHOUSE then
			return (game.tick & 1) == 1 and 1 or 2
		end

		if is_tunnel(pixel_to_tile_pos(ghost.actor.pos)) then
			return ((game.tick * 2) % 4) ~= 0 and 1 or 0
		end
		return (game.tick % 7) ~= 0 and 1 or 0
	end

	local function game_scatter_chase_phase()
		local t = since(game.round_started, game.tick)
		if t < 7 * 60 then
			return GHOSTSTATE_SCATTER
		elseif t < 27 * 60 then
			return GHOSTSTATE_CHASE
		elseif t < 34 * 60 then
			return GHOSTSTATE_SCATTER
		elseif t < 54 * 60 then
			return GHOSTSTATE_CHASE
		elseif t < 59 * 60 then
			return GHOSTSTATE_SCATTER
		elseif t < 79 * 60 then
			return GHOSTSTATE_CHASE
		elseif t < 84 * 60 then
			return GHOSTSTATE_SCATTER
		end
		return GHOSTSTATE_CHASE
	end

	local function game_update_ghost_state(ghost)
		local new_state = ghost.state

		if ghost.state == GHOSTSTATE_EYES then
			if nearequal_i2(ghost.actor.pos, { x = ANTEPORTAS_X, y = ANTEPORTAS_Y }, 1) then
				new_state = GHOSTSTATE_ENTERHOUSE
			end
		elseif ghost.state == GHOSTSTATE_ENTERHOUSE then
			if nearequal_i2(ghost.actor.pos, GHOST_HOUSE_TARGET_POS[ghost.type], 1) then
				new_state = GHOSTSTATE_LEAVEHOUSE
			end
		elseif ghost.state == GHOSTSTATE_HOUSE then
			if after_once(game.force_leave_house, game.tick, FORCE_LEAVE_HOUSE_TICKS) then
				new_state = GHOSTSTATE_LEAVEHOUSE
				start(game.force_leave_house, game.tick)
			elseif game.global_dot_counter_active then
				if ghost.type == GHOST_PINKY and game.global_dot_counter == 7 then
					new_state = GHOSTSTATE_LEAVEHOUSE
				elseif ghost.type == GHOST_INKY and game.global_dot_counter == 17 then
					new_state = GHOSTSTATE_LEAVEHOUSE
				elseif ghost.type == GHOST_CLYDE and game.global_dot_counter == 32 then
					new_state = GHOSTSTATE_LEAVEHOUSE
					game.global_dot_counter_active = false
				end
			elseif ghost.dot_counter == ghost.dot_limit then
				new_state = GHOSTSTATE_LEAVEHOUSE
			end
		elseif ghost.state == GHOSTSTATE_LEAVEHOUSE then
			if ghost.actor.pos.y == ANTEPORTAS_Y then
				new_state = GHOSTSTATE_SCATTER
			end
		else
			if before(ghost.frightened, game.tick, LEVELSPEC.fright_ticks) then
				new_state = GHOSTSTATE_FRIGHTENED
			else
				new_state = game_scatter_chase_phase()
			end
		end

		if new_state ~= ghost.state then
			if ghost.state == GHOSTSTATE_LEAVEHOUSE then
				ghost.next_dir = DIR_LEFT
				ghost.actor.dir = DIR_LEFT
			elseif ghost.state == GHOSTSTATE_ENTERHOUSE then
				disable(ghost.frightened)
			elseif ghost.state == GHOSTSTATE_SCATTER or ghost.state == GHOSTSTATE_CHASE then
				ghost.next_dir = reverse_dir(ghost.actor.dir)
			end
			ghost.state = new_state
		end
	end

	local function game_update_ghost_target(ghost)
		local pos = ghost.target_pos
		if ghost.state == GHOSTSTATE_SCATTER then
			pos = GHOST_SCATTER_TARGETS[ghost.type]
		elseif ghost.state == GHOSTSTATE_CHASE then
			local pm = game.pacman.actor
			local pm_pos = pixel_to_tile_pos(pm.pos)
			local pm_dir = dir_to_vec(pm.dir)
			if ghost.type == GHOST_BLINKY then
				pos = pm_pos
			elseif ghost.type == GHOST_PINKY then
				pos = add_i2(pm_pos, mul_i2(pm_dir, 4))
			elseif ghost.type == GHOST_INKY then
				local blinky_pos = pixel_to_tile_pos(game.ghost[GHOST_BLINKY].actor.pos)
				local p = add_i2(pm_pos, mul_i2(pm_dir, 2))
				local d = sub_i2(p, blinky_pos)
				pos = add_i2(blinky_pos, mul_i2(d, 2))
			elseif ghost.type == GHOST_CLYDE then
				if squared_distance_i2(pixel_to_tile_pos(ghost.actor.pos), pm_pos) > 64 then
					pos = pm_pos
				else
					pos = GHOST_SCATTER_TARGETS[GHOST_CLYDE]
				end
			end
		elseif ghost.state == GHOSTSTATE_FRIGHTENED then
			pos = { x = xorshift32() % DISPLAY_TILES_X, y = xorshift32() % DISPLAY_TILES_Y }
		elseif ghost.state == GHOSTSTATE_EYES then
			pos = { x = 13, y = 14 }
		end
		ghost.target_pos = pos
	end

	local function game_update_ghost_dir(ghost)
		if ghost.state == GHOSTSTATE_HOUSE then
			if ghost.actor.pos.y <= 17 * TILE then
				ghost.next_dir = DIR_DOWN
			elseif ghost.actor.pos.y >= 18 * TILE then
				ghost.next_dir = DIR_UP
			end
			ghost.actor.dir = ghost.next_dir
			return true
		elseif ghost.state == GHOSTSTATE_LEAVEHOUSE then
			local pos = ghost.actor.pos
			if pos.x == ANTEPORTAS_X then
				if pos.y > ANTEPORTAS_Y then
					ghost.next_dir = DIR_UP
				end
			else
				local mid_y = 17 * TILE + TILE // 2
				if pos.y > mid_y then
					ghost.next_dir = DIR_UP
				elseif pos.y < mid_y then
					ghost.next_dir = DIR_DOWN
				else
					ghost.next_dir = pos.x > ANTEPORTAS_X and DIR_LEFT or DIR_RIGHT
				end
			end
			ghost.actor.dir = ghost.next_dir
			return true
		elseif ghost.state == GHOSTSTATE_ENTERHOUSE then
			local pos = ghost.actor.pos
			local tile_pos = pixel_to_tile_pos(pos)
			local tgt_pos = GHOST_HOUSE_TARGET_POS[ghost.type]
			if tile_pos.y == 14 then
				if pos.x ~= ANTEPORTAS_X then
					ghost.next_dir = pos.x < ANTEPORTAS_X and DIR_RIGHT or DIR_LEFT
				else
					ghost.next_dir = DIR_DOWN
				end
			elseif pos.y == tgt_pos.y then
				ghost.next_dir = pos.x < tgt_pos.x and DIR_RIGHT or DIR_LEFT
			end
			ghost.actor.dir = ghost.next_dir
			return true
		end

		local dist_mid = dist_to_tile_mid(ghost.actor.pos)
		if dist_mid.x == 0 and dist_mid.y == 0 then
			ghost.actor.dir = ghost.next_dir

			local dir_vec = dir_to_vec(ghost.actor.dir)
			local lookahead_pos = add_i2(pixel_to_tile_pos(ghost.actor.pos), dir_vec)
			local dirs = { DIR_UP, DIR_LEFT, DIR_DOWN, DIR_RIGHT }
			local min_dist = math.huge

			for _, dir in ipairs(dirs) do
				if is_redzone(lookahead_pos) and dir == DIR_UP and ghost.state ~= GHOSTSTATE_EYES then
					goto continue
				end
				local revdir = reverse_dir(dir)
				local test_pos = clamped_tile_pos(add_i2(lookahead_pos, dir_to_vec(dir)))
				if revdir ~= ghost.actor.dir and not is_blocking_tile(test_pos) then
					local dist = squared_distance_i2(test_pos, ghost.target_pos)
					if dist < min_dist then
						min_dist = dist
						ghost.next_dir = dir
					end
				end
				::continue::
			end
		end

		return false
	end

	local function game_update_ghosthouse_dot_counters()
		if game.global_dot_counter_active then
			game.global_dot_counter = game.global_dot_counter + 1
		else
			for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
				local ghost = game.ghost[ghost_type]
				if ghost.dot_counter < ghost.dot_limit then
					ghost.dot_counter = ghost.dot_counter + 1
					break
				end
			end
		end
	end

	local function game_update_dots_eaten()
		game.num_dots_eaten = game.num_dots_eaten + 1
		if game.num_dots_eaten == NUM_DOTS then
			start(game.round_won, game.tick)
			clear_sounds()
		elseif game.num_dots_eaten == 70 or game.num_dots_eaten == 170 then
			start(game.fruit_active, game.tick)
		end
		if (game.num_dots_eaten & 1) ~= 0 then
			play_effect "eatdot1"
		else
			play_effect "eatdot2"
		end
	end

	local function game_update_actors()
		if game_pacman_should_move() then
			local actor = game.pacman.actor
			local wanted_dir = input_dir(actor.dir)
			if can_move(actor.pos, wanted_dir, true) then
				actor.dir = wanted_dir
			end
			if can_move(actor.pos, actor.dir, true) then
				actor.pos = move_pos(actor.pos, actor.dir, true)
				actor.anim_tick = actor.anim_tick + 1
			end

			local tile_pos = pixel_to_tile_pos(actor.pos)
			if is_dot(tile_pos) then
				local cell = board_cell_at(tile_pos)
				cell.dot = false
				game.score = game.score + SCORE_DOT
				start(game.dot_eaten, game.tick)
				start(game.force_leave_house, game.tick)
				game_update_dots_eaten()
				game_update_ghosthouse_dot_counters()
			end
			if is_pill(tile_pos) then
				local cell = board_cell_at(tile_pos)
				cell.pill = false
				game.score = game.score + SCORE_PILL
				game_update_dots_eaten()
				start(game.pill_eaten, game.tick)
				game.num_ghosts_eaten = 0
				for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
					start(game.ghost[ghost_type].frightened, game.tick)
				end
				play_ambient "frightened"
			end

			if game.active_fruit then
				local test_pos = pixel_to_tile_pos { x = actor.pos.x + TILE // 2, y = actor.pos.y }
				if equal_i2(test_pos, { x = 14, y = 20 }) then
					start(game.fruit_eaten, game.tick)
					game.score = game.score + LEVELSPEC.bonus_score
					game.fruit_score_value = LEVELSPEC.bonus_score
					game.fruit_score_pos = { x = 14, y = 20 }
					game.active_fruit = false
					play_effect "eatfruit"
				end
			end

			for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
				local ghost = game.ghost[ghost_type]
				local ghost_tile_pos = pixel_to_tile_pos(ghost.actor.pos)
				if equal_i2(tile_pos, ghost_tile_pos) then
					if ghost.state == GHOSTSTATE_FRIGHTENED then
						ghost.state = GHOSTSTATE_EYES
						start(ghost.eaten, game.tick)
						start(game.ghost_eaten, game.tick)
						game.num_ghosts_eaten = game.num_ghosts_eaten + 1
						ghost.eat_score = 100 * (1 << game.num_ghosts_eaten)
						game.score = game.score + ghost.eat_score
						game.freeze = game.freeze | FREEZETYPE_EAT_GHOST
						play_effect "eatghost"
					elseif ghost.state == GHOSTSTATE_CHASE or ghost.state == GHOSTSTATE_SCATTER then
						clear_sounds()
						start(game.pacman_eaten, game.tick)
						game.freeze = game.freeze | FREEZETYPE_DEAD
						if game.num_lives > 0 then
							start_after(game.ready_started, game.tick, PACMAN_EATEN_TICKS + PACMAN_DEATH_TICKS)
						else
							start_after(game.game_over_trigger, game.tick, PACMAN_EATEN_TICKS + PACMAN_DEATH_TICKS)
						end
					end
				end
			end
		end

		for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
			local ghost = game.ghost[ghost_type]
			game_update_ghost_state(ghost)
			game_update_ghost_target(ghost)
			local num_move_ticks = game_ghost_speed(ghost)
			for _ = 1, num_move_ticks do
				local force_move = game_update_ghost_dir(ghost)
				local actor = ghost.actor
				if force_move or can_move(actor.pos, actor.dir, false) then
					actor.pos = move_pos(actor.pos, actor.dir, false)
					actor.anim_tick = actor.anim_tick + 1
				end
			end
		end
	end

	local function game_tick()
		game.tick = game.tick + 1

		if now(game.ready_started, game.tick) then
			game_round_init()
			start_after(game.round_started, game.tick, READY_TICKS)
		end

		if now(game.round_started, game.tick) then
			game.freeze = game.freeze & ~FREEZETYPE_READY
			play_ambient "weeooh"
		end

		if now(game.fruit_active, game.tick) then
			game.active_fruit = true
		elseif after_once(game.fruit_active, game.tick, FRUITACTIVE_TICKS) then
			game.active_fruit = false
		end

		if after_once(game.fruit_eaten, game.tick, 2 * 60) then
			game.fruit_score_value = nil
			game.fruit_score_pos = nil
		end

		if (game.freeze & (FREEZETYPE_DEAD | FREEZETYPE_WON)) == 0
			and after_once(game.pill_eaten, game.tick, LEVELSPEC.fright_ticks) then
			play_ambient "weeooh"
		end

		if (game.freeze & FREEZETYPE_EAT_GHOST) ~= 0 and after_once(game.ghost_eaten, game.tick, GHOST_EATEN_FREEZE_TICKS) then
			game.freeze = game.freeze & ~FREEZETYPE_EAT_GHOST
		end

		if after_once(game.pacman_eaten, game.tick, PACMAN_EATEN_TICKS) then
			play_dead()
		end

		if (game.freeze & (FREEZETYPE_READY | FREEZETYPE_DEAD | FREEZETYPE_WON | FREEZETYPE_EAT_GHOST)) == 0 then
			game_update_actors()
		end

		if game.score > game.hiscore then
			game.hiscore = game.score
		end

		if now(game.round_won, game.tick) then
			game.freeze = game.freeze | FREEZETYPE_WON
			start_after(game.ready_started, game.tick, ROUNDWON_TICKS)
		end

		if now(game.game_over_trigger, game.tick) then
			input_disable()
		end
	end

	logic.game_init = game_init
	logic.game_tick = game_tick
end

local function draw_rect(x, y, width, height, color)
	batch:add(quad { width = width, height = height, color = color }, x, y)
end

local function draw_sprite(sprite, x, y)
	batch:add(sprite, x, y)
end

local function draw_tile(name, x, y)
	batch:add(assert(sprites.tiles[name], name), x, y)
end

local function playfield_tile_variant()
	if (game.freeze & FREEZETYPE_WON) ~= 0
		and after(game.round_won, game.tick, 60)
		and (since(game.round_won, game.tick) & 0x10) == 0 then
		return "white_border"
	end
	return "dot"
end

local function draw_playfield_panels()
	draw_rect(0, 0, W, H, COLOR_BLACK)
	draw_rect(MAP_X - 10, MAP_Y - 10, BOARD_W + 20, BOARD_H + 20, COLOR_PANEL)
	draw_rect(MAP_X - 6, MAP_Y - 6, BOARD_W + 12, BOARD_H + 12, COLOR_PANEL_LINE)
	draw_rect(MAP_X, MAP_Y, BOARD_W, BOARD_H, COLOR_BLACK)
	draw_rect(PANEL_X - 12, MAP_Y - 10, W - PANEL_X - 12, BOARD_H + 20, COLOR_PANEL)
	draw_rect(PANEL_X - 12, MAP_Y - 10, W - PANEL_X - 12, 2, COLOR_PANEL_LINE)
end

local function draw_background_and_dots()
	draw_playfield_panels()

	local variant = playfield_tile_variant()
	local show_pill = game.freeze ~= 0 or ((game.tick & 0x8) ~= 0)
	for y = BOARD_TILE_Y0, BOARD_TILE_Y1 do
		for x = 0, DISPLAY_TILES_X - 1 do
			local cell = game.board[y][x]
			local sx, sy = screen_from_tile_pos { x = x, y = y }
			if cell.wall then
				draw_tile("tile_" .. cell.tile_code .. "_" .. variant, sx, sy)
			elseif cell.door then
				if variant == "white_border" then
					draw_tile("tile_CF_white_border", sx, sy)
				else
					draw_tile("tile_CF_ghost_score", sx, sy)
				end
			elseif cell.dot then
				draw_tile("tile_10_dot", sx, sy)
			elseif cell.pill and show_pill then
				draw_tile("tile_14_dot", sx, sy)
			end
		end
	end

	if game.active_fruit then
		local sx, sy = screen_from_tile_pos { x = 13, y = 19 }
		draw_sprite(sprites.fruit, sx, sy + DRAW_TILE // 2)
	end
end

local function current_pacman_sprite()
	local actor = game.pacman.actor

	if (game.freeze & FREEZETYPE_EAT_GHOST) ~= 0 then
		return nil
	elseif (game.freeze & FREEZETYPE_READY) ~= 0 then
		return sprites.pacman_closed
	elseif (game.freeze & FREEZETYPE_DEAD) ~= 0 then
		if after(game.pacman_eaten, game.tick, PACMAN_EATEN_TICKS) then
			local death_tick = since(game.pacman_eaten, game.tick) - PACMAN_EATEN_TICKS
			local frame = clamp(death_tick // 8 + 1, 1, #sprites.pacman_death)
			return sprites.pacman_death[frame]
		end
		return sprites.pacman_closed
	end

	return sprites.pacman[actor.dir][(actor.anim_tick // 2) % 4 + 1]
end

local function current_ghost_sprite(ghost)
	if (game.freeze & FREEZETYPE_DEAD) ~= 0 and after(game.pacman_eaten, game.tick, PACMAN_EATEN_TICKS) then
		return nil
	elseif (game.freeze & FREEZETYPE_WON) ~= 0 then
		return nil
	end

	if ghost.state == GHOSTSTATE_EYES then
		if before(ghost.eaten, game.tick, GHOST_EATEN_FREEZE_TICKS) then
			return false
		end
		return sprites.ghost_eyes[ghost.next_dir]
	elseif ghost.state == GHOSTSTATE_ENTERHOUSE then
		return sprites.ghost_eyes[ghost.actor.dir]
	elseif ghost.state == GHOSTSTATE_FRIGHTENED then
		local frame = (since(ghost.frightened, game.tick) // 4) % 2 + 1
		if since(ghost.frightened, game.tick) > (FRIGHT_TICKS - 60) and (game.tick // 8) % 2 == 0 then
			return sprites.frightened_flash[frame]
		end
		return sprites.frightened[frame]
	end

	return sprites.ghost[ghost.type][ghost.next_dir][(ghost.actor.anim_tick // 8) % 2 + 1]
end

local function draw_actors()
	local pmx, pmy = screen_from_actor_pos(game.pacman.actor.pos)
	local pacman_sprite = current_pacman_sprite()
	if pacman_sprite then
		draw_sprite(pacman_sprite, pmx - DRAW_SPRITE // 2, pmy - DRAW_SPRITE // 2)
	end

	for ghost_type = GHOST_BLINKY, GHOST_CLYDE do
		local ghost = game.ghost[ghost_type]
		local gx, gy = screen_from_actor_pos(ghost.actor.pos)
		local ghost_sprite = current_ghost_sprite(ghost)
		if ghost_sprite == false then
			local score_sprite = sprites.ghost_score[ghost.eat_score]
			if score_sprite then
				draw_sprite(score_sprite, gx - DRAW_SPRITE // 2, gy - DRAW_SPRITE // 2)
			else
				local score_text = GHOST_SCORE_TEXT[ghost.eat_score] or tostring(ghost.eat_score)
				batch:add(label { block = ghost_score_block, text = score_text, width = 50, height = 18 }, gx - 12,
					gy - 8)
			end
		elseif ghost_sprite then
			draw_sprite(ghost_sprite, gx - DRAW_SPRITE // 2, gy - DRAW_SPRITE // 2)
		end
	end

	if game.fruit_score_value and game.fruit_score_pos then
		local sx, sy = screen_from_tile_pos(game.fruit_score_pos)
		batch:add(label { block = fruit_score_block, text = tostring(game.fruit_score_value), width = 60, height = 18 },
			sx - 6,
			sy + 8)
	end
end

local function draw_hud()
	batch:add(label { block = title_block, text = "PAC-MAN", width = 180, height = 32 }, PANEL_X, 36)
	batch:add(label { block = label_block, text = "Score", width = 120, height = 20 }, PANEL_X, 88)
	batch:add(label { block = value_block, text = tostring(game.score), width = 150, height = 22 }, PANEL_X, 110)

	batch:add(label { block = label_block, text = "Hi-Score", width = 120, height = 20 }, PANEL_X, 146)
	batch:add(label { block = value_block, text = tostring(game.hiscore), width = 150, height = 22 }, PANEL_X, 168)

	batch:add(label { block = label_block, text = "Round", width = 120, height = 20 }, PANEL_X, 204)
	batch:add(label { block = value_block, text = tostring(game.round + 1), width = 80, height = 22 }, PANEL_X, 226)

	batch:add(label { block = label_block, text = "Reserve Lives", width = 140, height = 20 }, PANEL_X, 262)
	batch:add(label { block = value_block, text = tostring(math.max(game.num_lives, 0)), width = 80, height = 22 },
		PANEL_X, 284)

	batch:add(label { block = label_block, text = "Fruit", width = 120, height = 20 }, PANEL_X, 320)
	batch:add(label { block = value_block, text = LEVELSPEC.fruit_name, width = 140, height = 22 }, PANEL_X, 342)

	batch:add(label { block = label_block, text = "Controls", width = 120, height = 20 }, PANEL_X, 390)
	batch:add(label { block = hint_block, text = "Arrows/WASD: Move", width = 180, height = 18 }, PANEL_X, 412)
	batch:add(label { block = hint_block, text = "P: Pause", width = 160, height = 18 }, PANEL_X, 432)
	batch:add(label { block = hint_block, text = "R: Restart", width = 160, height = 18 }, PANEL_X, 452)
	batch:add(label { block = hint_block, text = "Esc: Quit", width = 160, height = 18 }, PANEL_X, 472)
	batch:add(label { block = hint_block, text = "Gameplay follows pacman.c logic.", width = 220, height = 18 }, PANEL_X,
		506)
end

local function draw_overlay(paused)
	if paused then
		local x = MAP_X + BOARD_W // 2 - 70
		local y = MAP_Y + BOARD_H // 2 - 18
		draw_rect(x - 16, y - 10, 160, 54, COLOR_PANEL)
		draw_rect(x - 16, y - 10, 160, 2, COLOR_PANEL_LINE)
		batch:add(label { block = ready_block, text = "PAUSED", width = 160, height = 28 }, x - 16, y)
		return
	end

	if (game.freeze & FREEZETYPE_READY) ~= 0 then
		local x = MAP_X + BOARD_W // 2 - 68
		local y = MAP_Y + BOARD_H // 2 - 18
		draw_rect(x - 18, y - 10, 164, 54, COLOR_PANEL)
		draw_rect(x - 18, y - 10, 164, 2, COLOR_PANEL_LINE)
		batch:add(label { block = ready_block, text = "READY!", width = 164, height = 28 }, x - 18, y)
		return
	end

	if input.enabled == false then
		local x = MAP_X + BOARD_W // 2 - 98
		local y = MAP_Y + BOARD_H // 2 - 36
		draw_rect(x - 18, y - 12, 220, 76, COLOR_PANEL)
		draw_rect(x - 18, y - 12, 220, 2, COLOR_PANEL_LINE)
		batch:add(label { block = over_block, text = "GAME OVER", width = 220, height = 30 }, x - 18, y)
		batch:add(label { block = hint_block, text = "Press R to restart", width = 160, height = 18 }, x + 18, y + 34)
	elseif (game.freeze & FREEZETYPE_WON) ~= 0 then
		local x = MAP_X + BOARD_W // 2 - 84
		local y = MAP_Y + BOARD_H // 2 - 36
		draw_rect(x - 18, y - 12, 196, 76, COLOR_PANEL)
		draw_rect(x - 18, y - 12, 196, 2, COLOR_PANEL_LINE)
		batch:add(label { block = win_block, text = "ROUND CLEAR", width = 196, height = 30 }, x - 18, y)
	end
end

local function reset_game()
	input.pause = false
	input.restart = false
	game.tick = 0
	logic.game_init()
end

local scene = {}

function scene.reset()
	reset_game()
	return "playing"
end

function scene.playing()
	while true do
		if consume_restart() then
			return "reset"
		end
		if input.enabled and consume_pause() then
			return "paused"
		end
		logic.game_tick()
		if not input.enabled then
			return "over"
		end
		flow.sleep(0)
	end
end

function scene.paused()
	while true do
		if consume_restart() then
			return "reset"
		end
		if input.enabled and consume_pause() then
			return "playing"
		end
		flow.sleep(0)
	end
end

function scene.over()
	while true do
		if consume_restart() then
			return "reset"
		end
		flow.sleep(0)
	end
end

flow.load(scene)
flow.enter "reset"

local callback = {}

function callback.frame()
	local current_scene = flow.update()

	view.begin(batch)
	draw_background_and_dots()
	draw_actors()
	draw_hud()
	draw_overlay(current_scene == "paused")
	view.finish(batch)
end

function callback.key(keycode, state)
	if keycode == KEY_ESCAPE and state == KEYSTATE_PRESS then
		app.quit()
		return
	end

	if keycode == KEY_R and state == KEYSTATE_PRESS then
		input.restart = true
		return
	end

	if keycode == KEY_P and state == KEYSTATE_PRESS and input.enabled then
		input.pause = true
		return
	end

	local pressed = state == KEYSTATE_PRESS
	if keycode == KEY_UP
		or keycode == KEY_W
		or keycode == KEY_DOWN
		or keycode == KEY_S
		or keycode == KEY_LEFT
		or keycode == KEY_A
		or keycode == KEY_RIGHT
		or keycode == KEY_D then
		key_down[keycode] = pressed
		update_move_input()
	end
end

function callback.window_resize(w, h)
	view.resize(w, h)
end

return callback