-- Writes a Forge description for a KarisFramework game from the game's own script: the script is -- Lua, so it is loaded with the engine stubbed out, its settings and tables read, and setupMoves -- called for every level and scene. Nothing is transcribed by hand. -- -- lua5.4 util/forgePortKaris.lua
[] -- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/TimeGal Timegal.singe -- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/hypseus/Astroboy/singe/Astroboy Astroboy.singe -- lua5.4 util/forgePortKaris.lua ~/claude/singetest/hypseus/starblazers/singe/starblazers starblazers.singe -- -- The description goes beside the script as .forge (the script's stem unless given). It is -- data for the qte behaviour in Author.singe, which plays the framework's game loop. -- -- The framework is found where the game keeps it: KarisFramework/Script beside the script (the -- library's games), Structure or Script beside it, or Framework or FrameworkKimmy beside the -- game's folder (the Hypseus zip-ROM layout, singe// and singe/Framework/). Its -- version is written into the description, since 3.32b differs from 3.31c in a few things a -- player can see (and MazescaterFramework beside the game, LINEA's Karis 3.31c with compound -- moves, is marked as such there); a Kimmy Script Engine game gets the kimmy behaviour, and its saves and its -- die-and-retry records are snapshotted too, since its new-game menu reads them. A game of -- RDG's map-mode lineage (no framework folder: its globals.singe is beside its script and its -- levels are functions of its main.singe) gets the rdg behaviour, which runs those functions -- itself; its moves are not snapshotted, since the game draws some of them at random at play. local dir = arg[1] or error("a game directory") local main = arg[2] or error("the main script") local name = arg[3] or main:gsub("%.singe$", "") -- ===== Loading the game's script ============================================================== local env = setmetatable({}, { __index = _G }) local function nothing() return 0 end for _, key in ipairs({ "soundLoad", "spriteLoad", "fontLoad", "fontSelect", "fontQuality", "colorForeground", "colorBackground", "discGetWidth", "discGetHeight", "overlaySetResolution", "discSetFPS", "debugPrint", "discSkipToFrame", "discPause", "discPlay", "singeSetGameName", "keyboardSetMode", "overlayClear", "singeGetDataPath", "spriteGetWidth", "spriteGetHeight", "setOverlaySize", "setOverlayResolution", "scoreBezelEnable", "scoreBezelGetState", "discAudioSuffix", "singeSetQuitKeyEnabled", "keyboardCatchQuit", "ratioGetX", "ratioGetY", "vldpGetScale", "overlayGetWidth", "overlayGetHeight", "fontGetHeight", "fontGetWidth", "spriteLoadFrames", "mouseGetPosition", "keyboardCatchQuit", "singeSetQuitKeyEnabled" }) do env[key] = nothing end -- Where the framework's globals are, and its version from their header. local frameworkFile local frameworkVersion local kimmy = false local rdg = false local timegal = false local sdq = false local mazescater = false -- An American Laser Games title (Mad Dog McCree and its kin): no framework at all, its -- hitbox files beside the script; the alg behaviour plays it over the level flows written -- by hand in <stem>.levels.lua beside the script. local alg = (function() local handle = io.open(dir .. "/" .. main, "rb") local text = handle and handle:read("a") or "" if handle then handle:close() end -- The family's mark: a shot judged against a hitmap of the frame (shooterHit), its -- hitbox files loaded by name, or the typing edition's own globals. return (text:find("function shooterHit", 1, true) ~= nil) or (text:find('dofile(MYDIR .. "hitbox-', 1, true) ~= nil) or (text:find("te-globals.singe", 1, true) ~= nil) end)() -- Adventures in Videoland (Rollercoaster): a text adventure over the disc, its rooms, objects, -- and furniture tables in the script; the videoland behaviour plays its loop over them. local videoland = (function() local handle = io.open(dir .. "/" .. main, "rb") local text = handle and handle:read("a") or "" if handle then handle:close() end return (text:find("STATE_GET_NAME", 1, true) ~= nil) and (text:find("ROOMS", 1, true) ~= nil) end)() -- Hologram Time Traveler (RDG2010's Singe edition): its own loop over segment tables shuffled -- at start, read like the American Laser Games titles (the script is its data) and played by -- the timetraveler behaviour in the vocabulary beside it. local timetraveler = (function() local handle = io.open(dir .. "/" .. main, "rb") local text = handle and handle:read("a") or "" if handle then handle:close() end return text:find("TIME TRAVELER (SINGE EDITION)", 1, true) ~= nil end)() if videoland or timetraveler then alg = true end -- (hq-globals.singe, Time Gal HD's, comes before a plain globals.singe beside it: that title -- keeps its Singe 1 ancestor's files too.) for _, candidate in ipairs({ dir .. "/MazescaterFramework/Script/globals.singe", dir .. "/KarisFramework/Script/globals.singe", dir .. "/Structure/globals.singe", dir .. "/Script/globals.singe", dir .. "/../Framework/globals.singe", dir .. "/../FrameworkKimmy/globals.singe", dir .. "/hq-globals.singe", dir .. "/globals.singe" }) do local handle = io.open(candidate, "r") if handle then local text = handle:read("a") or "" -- The MazescaterFramework (LINEA): Karis 3.31c with compound moves, its version -- marked as the framework's own so the loop tells the two apart. mazescater = (candidate == dir .. "/MazescaterFramework/Script/globals.singe") frameworkFile = candidate frameworkVersion = (mazescater and "Mazescater " or "") .. (text:match("VERSION:%s*([%w%.]+)") or "") kimmy = text:find("KIMMY SCRIPT ENGINE", 1, true) ~= nil timegal = (candidate == dir .. "/hq-globals.singe") rdg = (candidate == dir .. "/globals.singe") or timegal -- Super Don Quixote: map-mode data under a Karis 3.31c judging loop, whose globals -- name the Karis kinds (HOLDUP and the rest). sdq = rdg and (not timegal) and (text:find("HOLDUP", 1, true) ~= nil) handle:close() break end end if alg then -- The game's own script is its globals: everything it declares at load is its data. frameworkFile = dir .. "/" .. main frameworkVersion = nil rdg = true end assert(frameworkFile, "no framework globals beside " .. dir) env.singeGetScriptPath = function() return dir .. "/" .. main end env.random = { new = function() return 0 end } -- The map-mode scripts seed from os.clock at load; plain Lua wants a whole number for that. env.os = setmetatable({ clock = function() return 1 end }, { __index = os }) -- A game that asks the filesystem whether its saved config exists (cfgReadPath, through lfs) -- hears that it does not, and reads the shipped copy. env.require = function() return { attributes = function() return nil end } end -- A game that reads its config at load (Mad Dog McCree's readConfig runs as the script does) -- reads the real file when the path names one, and nothing otherwise. local ioCurrent = nil env.io = { open = function() return nil end, input = function(path) -- The path as given, else its name under the game's Cfg/ folder (the script's -- MYDIR is a stub here). local base = (type(path) == "string") and path:match("([^/\\]+)$") or nil ioCurrent = nil for _, candidate in ipairs({ path, base and (dir .. "/../Cfg/" .. base), base and (dir .. "/Cfg/" .. base), base and (dir .. "/" .. base) }) do if (type(candidate) == "string") and (ioCurrent == nil) then ioCurrent = io.open(candidate, "r") end end return ioCurrent end, output = function() return nil end, read = function(what) if ioCurrent then return ioCurrent:read(what or "l") end return nil end, write = function() end, close = function(handle) if handle and (handle == ioCurrent) then ioCurrent:close() ioCurrent = nil end end, lines = function() return function() return nil end end } env.mouseHowMany = function() return 0 end env.discSearch = function() end env.discSetFPS = function() end env.singeWantsCrosshairs = function() return false end -- A setupMoves that reads the game's state (Space Ace picks a scene by which levels are -- beaten) sees nothing beaten here; the qte behaviour runs the script itself as it plays. env.stage = setmetatable({}, { __index = function() return { false, false, 0 } end }) env.scene = setmetatable({}, { __index = function() return setmetatable({}, { __index = function() return { 0, false } end }) end }) env.Tiers = {} env.Level = {} env.Death = {} env.move = {} env.choice = {} env.path = {} env.timed = {} env.dofile = function(path) if alg and (path ~= frameworkFile) then -- The title's own files (its hitbox tables, its board, its service) run here too, -- under the stubs: the script calls what they define at load (readConfig). -- (Beside the script, or in a Script/ folder beside it, as the HD copies keep theirs.) local base = path:match("([^/\\]+)$") local own = loadfile(dir .. "/" .. base, "t", env) or loadfile(dir .. "/Script/" .. base, "t", env) if own then own() end return end if path:match("globals%.singe$") then -- The framework's globals name the moves; what it loads beyond that is stubbed. local chunk = assert(loadfile(frameworkFile, "t", env)) local inner = env.dofile env.dofile = function() end chunk() env.dofile = inner end end if alg then -- A hand-written game touches whatever engine calls it likes at load (videoGetVolume, ...): -- any name the stubs do not know answers as one that does nothing. for _, name in ipairs({ "pairs", "ipairs", "next", "type", "tonumber", "tostring", "select", "pcall", "error", "assert", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", "print", "unpack" }) do if rawget(env, name) == nil then env[name] = _G[name] or (name == "unpack" and table.unpack) or nil end end setmetatable(env, { __index = function(_, key) local real = _G[key] if real ~= nil then return real end return nothing end }) -- Its framework.singe defines the switch names and the defaults the script reads. local frame = loadfile(dir .. "/framework.singe", "t", env) if frame then frame() end end -- The game's own directory (the Script/ folder's parent when it has one), for the files the -- script joins onto MYDIR at load. env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir assert(loadfile(dir .. "/" .. main, "t", env))() -- ===== The settings =========================================================================== -- The dips, by name, from what the game was last saved with or shipped with: the framework -- reads Cfg/game.cfg and falls back to Cfg/default.cfg. local function settings() local found = {} for _, file in ipairs({ dir .. "/Cfg/game.cfg", dir .. "/Cfg/default.cfg" }) do local handle = io.open(file, "r") if handle then for line in handle:lines() do local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)") if key and (found[key] == nil) then found[key] = tonumber(value) end end handle:close() end end return found end -- ===== The moves ============================================================================== local function copy(t) local out = {} for key, value in pairs(t or {}) do -- A function is not data (a stub of the converter's, a game's own helper). if type(value) == "table" then out[key] = copy(value) elseif type(value) ~= "function" then out[key] = value end end return out end -- A game's moves may differ by difficulty (Space Ace's do: setupMoves reads dip_Difficulty): -- each scene is read at every difficulty, and kept once when they agree. local function scenesAt(level, difficulty) local scenes = {} env.dip_Difficulty = difficulty for scene = 1, env.Level[level][4] do env.move = {} env.choice = {} env.path = {} env.timed = {} env.sceneStart = nil env.sceneEnd = nil env.totalMoves = 0 env.setupMoves(level, scene) local moves = {} for index = 1, env.totalMoves or 0 do moves[index] = copy(env.move[index]) end scenes[scene] = { start = env.sceneStart, finish = env.sceneEnd, moves = moves, choices = copy(env.choice), paths = copy(env.path), timed = copy(env.timed) } end return scenes end local function same(a, b) if type(a) ~= type(b) then return false end if type(a) ~= "table" then return a == b end for key, value in pairs(a) do if not same(value, b[key]) then return false end end for key in pairs(b) do if a[key] == nil then return false end end return true end -- The scenes of a level: play, or playBy difficulty when the difficulties differ. local function scenesOf(level) local by = {} local differ = false for difficulty = 0, 3 do by[difficulty] = scenesAt(level, difficulty) if not same(by[difficulty], by[0]) then differ = true end end env.dip_Difficulty = nil if differ then return nil, by end return by[0], nil end -- ===== Writing ================================================================================ local out = {} local function put(line) out[#out + 1] = line end local function lua(value, indent) indent = indent or "" if type(value) == "table" then local keys = {} local isList = (#value > 0) local parts = {} for key in pairs(value) do keys[#keys + 1] = key end table.sort(keys, function(a, b) if type(a) == type(b) then return a < b end return type(a) == "number" end) for _, key in ipairs(keys) do local item = lua(value[key], indent .. "\t") if isList and (type(key) == "number") and (key >= 1) and (key <= #value) and (math.floor(key) == key) then parts[#parts + 1] = item elseif type(key) == "string" and key:match("^[%a_][%w_]*$") then parts[#parts + 1] = key .. " = " .. item else parts[#parts + 1] = "[" .. lua(key) .. "] = " .. item end end local text = "{ " .. table.concat(parts, ", ") .. " }" if #text > 110 then text = "{\n" .. indent .. "\t" .. table.concat(parts, ",\n" .. indent .. "\t") .. "\n" .. indent .. "}" end return text elseif type(value) == "string" then return string.format("%q", value) end return tostring(value) end local levels = {} -- A map-mode game's levels are functions of its main.singe, run at play; nothing to read here. for index = 1, (rdg and 0 or env.finalstage) do local level = env.Level[index] local play, playBy = scenesOf(index) levels[index] = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy } end if (not rdg) and env.AllowSecret and env.Level[env.levelSecret] then local level = env.Level[env.levelSecret] local play, playBy = scenesOf(env.levelSecret) levels.secret = { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], play = play, playBy = playBy } end local tiers = {} for index = 0, (env.Tiers and env.Tiers[0] and env.Tiers[0][1]) or 0 do tiers[index + 1] = copy(env.Tiers[index]) end local dips = settings() -- A map-mode game keeps its dips and its board in one file, the one its service.singe reads -- (game.cfg or the game's own name beside the script; the later copies read theirs from Cfg/ -- beside the Script/ folder, through cfgReadPath): the dips as name = value lines (the names -- are the game's own), a blank, then ten lines of NAME,score. local function mapModeConfigFile() local handle = (timegal and io.open(dir .. "/hq-service.singe", "rb")) or io.open(dir .. "/service.singe", "rb") or io.open(dir .. "/Script/service.singe", "rb") or io.open(dir .. "/te-service.singe", "rb") local name = "game.cfg" if handle then local text = handle:read("a") handle:close() -- The config the game reads first: through cfgReadPath (the later copies, whose -- MYDIR reads name a default.cfg fallback too), else joined onto MYDIR. name = text:match('io%.input%(cfgReadPath%("([^"]+)"%)') or text:match('io%.input%(MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input,%s*MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input%("[^"]-([^"/]+%.cfg)"%)') or name name = name:gsub("^/", "") end for _, candidate in ipairs({ dir .. "/" .. name, dir .. "/../Cfg/" .. name, dir .. "/../" .. name }) do local found = io.open(candidate, "r") if found then found:close() return candidate end end return dir .. "/" .. name end local function mapModeConfig() local found = {} local board = {} local extra = {} -- Super Don Quixote's best percent per level, after its board. local handle = io.open(mapModeConfigFile(), "r") if handle then for line in handle:lines() do local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)") local who, score = line:match("^%s*([^,=]+),(%d+)%s*$") if key then found[key] = tonumber(value) elseif who and (#board < 10) then board[#board + 1] = { who, tonumber(score) } elseif who then extra[#extra + 1] = { who, tonumber(score) } end end handle:close() end return found, board, extra end -- What a map-mode title does differently from the lineage, by the script's stem: the sound of -- a right move when it is not sndright, and how long the intro's command screen holds. local RDG_TWEAKS = { freedomfighter = { rightSound = "sndshot", introHold = 15 } } if rdg then local rdgDips, rdgBoard, rdgExtra = mapModeConfig() dips = rdgDips RDG_BOARD = rdgBoard RDG_EXTRA = rdgExtra end -- Super Don Quixote's level order is two tables hardcoded in its doLevel: the level after a -- beaten one, and the level after a scene that did not beat its level (the game alternates -- two levels' scenes); read from the script's text as the game's data. local function sdqOrder() local handle = io.open(dir .. "/main.singe", "rb") local beaten = {} local unfinished = {} if handle == nil then return nil end local text = handle:read("a") handle:close() local body = text:match("function doLevel%(%).-\nend") or "" local split = body:find("if CompleteLevel then", 1, true) or #body local before, after = body:sub(1, split), body:sub(split) for from, to in before:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do beaten[tonumber(from)] = tonumber(to) end for from, to in after:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do unfinished[tonumber(from)] = tonumber(to) end return { beaten = beaten, unfinished = unfinished } end -- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score -- that reaches it goes to the board's screens at the end. local function highScores() local found = {} local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") if handle then for line in handle:lines() do local who, score = line:match("^%s*([^,]+),(%d+)") if who and (#found < 10) then found[#found + 1] = { who, tonumber(score) } end end handle:close() end return found end -- Kimmy's "die and retry" records, one per difficulty: the four lines after the three boards -- and the two trophy blocks of Cfg/hscore.cfg, read as the framework reads them (a line -- without a comma-and-name counts 100). local function dieAndRetry() local records = { 100, 100, 100, 100 } local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") if handle == nil then return records end local lines = {} for line in handle:lines() do lines[#lines + 1] = line end handle:close() -- 10 + blank, 10 + blank, 10 + blank, 4 + blank, 4 + blank: the records begin at line 44. for k = 1, 4 do local line = lines[43 + k] local score = line and (line:find("!", 1, true) == nil) and line:match("^[^,]*,(.*)$") records[k] = tonumber(score) or 100 end return records end -- Kimmy's save slots, Cfg/s1.cfg to s6.cfg: a line of eleven fields in the framework's -- punctuation, then a line per level (its order, started, beaten, deaths). local function saves() local slots = {} for slot = 1, 6 do local handle = io.open(dir .. "/Cfg/s" .. slot .. ".cfg", "r") if handle then local first = handle:read("l") or "" local line = {} local rest = first for _, mark in ipairs({ ",", "!", "?", ";", ":", "A", "B", "C", "D", "E", "F" }) do local at = rest:find(mark, 1, true) if at == nil then break end line[#line + 1] = rest:sub(1, at - 1) rest = rest:sub(at + 1) end local levels = {} for entry in handle:lines() do local order, started, beaten, deaths = entry:match("^(%d+)A(%a+)B(%a+)C(%d+)D") if order then levels[#levels + 1] = { tonumber(order), started == "true", beaten == "true", tonumber(deaths) } end end handle:close() slots[slot] = { line = line, levels = levels } end end return slots end -- Everything the game's script declared, under its own names: the scores, the offsets, the -- flags, the Level and Death tables, the play order and the tiers. The qte behaviour takes -- these as its state, and runs the script itself when it can, for a setupMoves that reads the -- state as it plays. local settings = {} for key, value in pairs(env) do local kind = type(value) if (kind == "number") or (kind == "string") or (kind == "boolean") or (kind == "table") then if not (key == "move" or key == "choice" or key == "path" or key == "timed" or key == "io" or key == "os" or key == "string" or key == "math" or key == "table" or key == "sceneStart" or key == "sceneEnd" or key == "totalMoves" or key == "dip_Difficulty" or key == "MYDIR" or key == "BASEDIR" or key == "SINGE_LEGACY_SPRITE_ARGS" or key == "random" or key == "os") then settings[key] = (kind == "table") and copy(value) or value end end end -- (A game that sets its rate by number rather than by name has no MovieFPS; under the -- hand-written games' catch-all stubs the name answers as a function, which is not data.) settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil -- The level flows of an American Laser Games title, written by hand beside its script. local function algLevels() local chunk = loadfile(dir .. "/" .. main:gsub("%.singe$", "") .. ".levels.lua", "t", {}) return chunk and chunk() or nil end local qte = { script = main, addons = (not rdg) and "Script/addons.singe" or nil, framework = frameworkVersion, hsDR = kimmy and dieAndRetry() or nil, saves = kimmy and saves() or nil, settings = settings, dips = dips, highScores = rdg and RDG_BOARD or highScores(), tweaks = rdg and RDG_TWEAKS[name] or nil, sdq = sdq and sdqOrder() or nil, percents = sdq and RDG_EXTRA or nil, flows = alg and algLevels() or nil, levels = (not rdg) and levels or nil } if videoland then -- The objects are set up by the game's own reset; the rooms, furniture, and directions -- stand as the script declares them. env.resetGameData() put("-- " .. name .. ", ported to Forge from its Adventures in Videoland script by util/forgePortKaris.lua:") put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop") put("-- under the console look; both live in Vocabulary.singe beside this file.") put("return {") put("\ttitle = " .. lua(name) .. ",") put("\tvocabulary = \"Vocabulary.singe\",") put("\tplayers = 1,") put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") put("\tvars = { room = 1, turns = 0 },") put("") put("\ttypes = {") put("\t\tgame = { look = { kind = \"console\" }, behaviours = { { kind = \"videoland\" } } }") put("\t},") put("") put("\trooms = {") put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }") put("\t},") put("") put("\trules = {},") put("") put("\tvideoland = " .. lua({ script = main, rooms = env.ROOMS, objects = env.OBJECTS, furniture = env.FURNITURE, directions = env.DIRECTIONS, turns = 150 }, "\t")) put("}") local path = dir .. "/" .. name .. ".forge" local file = assert(io.open(path, "w")) file:write(table.concat(out, "\n") .. "\n") file:close() print("wrote " .. path .. ": " .. #env.ROOMS .. " rooms, " .. #env.OBJECTS .. " objects") return end local loop = (timetraveler and "timetraveler") or (alg and "alg") or (timegal and "timegal" or (sdq and "sdq" or (rdg and "rdg" or (kimmy and "kimmy" or "qte")))) put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its " .. (timetraveler and "Time Traveler (Singe Edition)" or alg and "American Laser Games" or (timegal and "Time Gal (Singe Edition)" or (rdg and "map-mode" or (kimmy and "Kimmy Script Engine" or (mazescater and "MazescaterFramework" or "KarisFramework"))))) .. " script by util/forgePortKaris.lua:") put("-- the framework's tables as data for the " .. loop .. " behaviour, which plays the framework's game loop.") put("return {") put("\ttitle = " .. lua(name) .. ",") if alg then -- The title's mechanics and screens are a vocabulary beside its script. put("\tvocabulary = \"Vocabulary.singe\",") end put("\tplayers = 1,") put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },") put("") put("\ttypes = {") -- A hand-written title's vocabulary draws its HUD as its own files do (the hud look); a -- framework's is drawn by the runtime's look for that loop. put("\t\tgame = { look = { kind = \"" .. (alg and "hud" or (loop .. "Hud")) .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }") put("\t},") put("") put("\trooms = {") put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }") put("\t},") put("") put("\trules = {},") put("") put("\tqte = " .. lua(qte, "\t")) put("}") local path = dir .. "/" .. name .. ".forge" local file = assert(io.open(path, "w")) file:write(table.concat(out, "\n") .. "\n") file:close() print("wrote " .. path .. ": " .. #levels .. " levels, " .. #env.Death .. " deaths")