A folder on your machine is not something anyone can play. Two things stand between what you have and what you can hand to a friend: the game has to remember something between runs, and it has to travel as one file.
Both turn out to be one idea. The moment your game becomes a single file, it is living inside a read-only archive, and the folder it came from no longer exists as far as the code is concerned. Every decision on this page follows from that.
You cannot write next to main.lua. Not because LÖVE is being strict, but because once you ship, main.lua is a compressed entry inside a zip file, and there is no "next to" to write to.
So LÖVE gives every game exactly one writable place, the save directory, somewhere in the operating system's per-user application data. Every path you hand to love.filesystem.write is relative to it, and there is no way to write outside it by accident.
Windows %APPDATA%\LOVE\skynestmacOS ~/Library/Application Support/LOVE/skynestLinux ~/.local/share/love/skynestThe last part of that path is the identity, and it is yours to choose in conf.lua. If you never set one, LÖVE makes one up from the name of the folder or the .love file your game arrived in, which works right up until the day you rename the folder and every saved game silently disappears. Setting t.identity is how you promise your players their save file survives you reorganizing your desktop.
The first time anyone runs your game there is no save file, and that is not an error condition — it is the normal case, exactly once. love.filesystem.read returns the contents, or returns nil and a message. It does not raise. That is the same nil-plus-a-message convention Activity 1.12 met in io.open, and it means a missing high score costs you one or 0.
If you would rather ask before you read, that is what getInfo is for. It hands back a table with the type and size, or nil.
if love.filesystem.getInfo("highscore.txt") then -- it is there, and getInfo told you its size tooendHere is a bug worth more than the feature it hides in. love.filesystem.read returns two values — the contents and the number of bytes. And tonumber takes two arguments — the string and the base to read it in.
-- Looks tidy. Reads a saved 11 as base 2 and hands you 3.local function loadHighScore() return tonumber(love.filesystem.read("highscore.txt")) or 0end
-- Reads a saved 11 as eleven.local function loadHighScore() local contents = love.filesystem.read("highscore.txt") return tonumber(contents) or 0endA call in the last argument position keeps all of its return values, which is the rule from Activity 1.7 arriving with teeth. So the tidy version does not read your high score in base ten. It reads it in a base equal to its own length, and I measured what that costs:
or 0 catches it.Every single-digit high score is a crash on the next launch. Not on the machine you wrote it on — that one already has a two-digit file. On somebody else's, the first time they play. Assigning to a local first is not verbosity. It is how you cut a function call down to one value.
That is the entire format. Rename your zip to .love and LÖVE will run it, on any operating system, as long as the person has LÖVE installed. There is exactly one rule, and everybody breaks it once.
main.lua must be at the top of the archive, not inside a folder in it. LÖVE mounts the archive as the root of the game's file system, so if the zip contains skynest/main.lua then main.lua is not at the root and LÖVE shows you the no-game screen. You zip the contents of the folder, never the folder.
$ unzip -Z1 skynest.love $ unzip -Z1 broken.loveenemy.lua skynest/collision.lua skynest/enemy.luaship.png skynest/.DS_Storemain.lua skynest/collision.luaexplode.wav skynest/ship.pngconf.lua skynest/main.luashoot.wav skynest/explode.wavhit.wav skynest/conf.lua skynest/shoot.wav skynest/hit.wavThose are two real archives built from the same eight files. The difference is one cd, and the one on the right does not run. Those are archive listings — every unzip tool can show you one, whatever it calls the command. Note that the broken one also swept up a .DS_Store that macOS left lying in the folder; Windows leaves Thumbs.db and desktop.ini around in exactly the same way. Harmless here, but it tells you something about zipping a folder you have not looked inside.
A .love needs LÖVE installed. If you want to hand someone a program instead, you fuse: LÖVE's own executable and your .love are concatenated into one file, and LÖVE finds the zip stuck to its own back end when it starts. On Windows the whole operation is literally copy /b love.exe+skynest.love skynest.exe. On macOS you copy love.app and drop the .love inside it, in Contents/Resources.
The catch is that the rest of the files that shipped with LÖVE have to travel with your executable, and a macOS bundle you have not paid Apple to notarize will be refused by Gatekeeper on your friend's machine. For sharing a game you made for fun, the .love file is the better answer — it is one small file, it works on every platform at once, and it asks nothing of you.
Two edits to main.lua, one to conf.lua, and then you package it. enemy.lua and collision.lua are untouched, and this is the last time you will open them.
t.identity so the save directory has a name you chose.t.version. It is the version of LÖVE you built against. If someone runs your game on a future major release, LÖVE shows them a compatibility warning instead of a stack trace.-- conf.lua
function love.conf(t) t.identity = "skynest" t.version = "11.5"
t.window.width = 800 t.window.height = 600 t.window.title = "Skynest"endhighScore local to the program group, not the per-run group. It is the one number in this game that deliberately survives resetGame.loadHighScore above love.load and call it there. Two lines, not one, for the reason above.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal titleFontlocal soundslocal highScorelocal spawnEvery = 0.8local gameState = "menu"
local playerlocal bulletslocal fireCooldownlocal enemieslocal spawnTimerlocal scorelocal liveslocal invulnlocal shake
local function resetGame() player = { x = 400, y = 500, size = 40, speed = 300 } bullets = {} fireCooldown = 0 enemies = {} spawnTimer = 0 score = 0 lives = 3 invuln = 0 shake = 0end
local function loadHighScore() local contents = love.filesystem.read("highscore.txt") return tonumber(contents) or 0end
function love.load() love.graphics.setBackgroundColor(0.05, 0.05, 0.1) math.randomseed(os.time()) ship = love.graphics.newImage("ship.png") font = love.graphics.newFont(20) titleFont = love.graphics.newFont(48) love.graphics.setFont(font)
sounds = { shoot = love.audio.newSource("shoot.wav", "static"), hit = love.audio.newSource("hit.wav", "static"), explode = love.audio.newSource("explode.wav", "static"), } sounds.shoot:setVolume(0.35) sounds.hit:setVolume(0.6)
highScore = loadHighScore() resetGame()end
function love.keypressed(key) if key == "escape" then love.event.quit() elseif key == "space" and gameState ~= "playing" then resetGame() gameState = "playing" endend
function love.update(dt) shake = shake - dt
if gameState ~= "playing" then return end
if love.keyboard.isDown("left", "a") then player.x = player.x - player.speed * dt end if love.keyboard.isDown("right", "d") then player.x = player.x + player.speed * dt end
local half = player.size / 2 player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt invuln = invuln - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 }) fireCooldown = 0.15 sounds.shoot:clone():play() end
for i = #bullets, 1, -1 do local b = bullets[i] b.y = b.y - b.speed * dt if b.y < -20 then table.remove(bullets, i) end end
spawnTimer = spawnTimer - dt if spawnTimer <= 0 then table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40))) spawnTimer = spawnEvery end
for i = #enemies, 1, -1 do local e = enemies[i] e:update(dt) if e.y > love.graphics.getHeight() + 20 then table.remove(enemies, i) end end
if invuln <= 0 then for i = #enemies, 1, -1 do if collision.overlaps(enemies[i], player) then table.remove(enemies, i) lives = lives - 1 invuln = 2 shake = 0.4 sounds.explode:play() if lives <= 0 then gameState = "gameover" end break end end end
for i = #enemies, 1, -1 do for j = #bullets, 1, -1 do if collision.overlaps(enemies[i], bullets[j]) then table.remove(bullets, j)
local thud = sounds.hit:clone() thud:setPitch(0.9 + math.random() * 0.3) thud:play()
enemies[i].hp = enemies[i].hp - 1 if enemies[i].hp <= 0 then table.remove(enemies, i) score = score + 1 end break end end endend
local function drawHud() love.graphics.setColor(1, 1, 1) love.graphics.print("Score: " .. score, 10, 12) love.graphics.print("Best: " .. highScore, 10, 36)
for i = 1, lives do local x = love.graphics.getWidth() - 24 - (i - 1) * 30 love.graphics.draw(ship, x, 24, 0, 1, 1, ship:getWidth() / 2, ship:getHeight() / 2) endend
local function drawOverlay(title, subtitle) love.graphics.setColor(0, 0, 0, 0.7) love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
love.graphics.setColor(1, 1, 1) love.graphics.setFont(titleFont) love.graphics.printf(title, 0, 220, love.graphics.getWidth(), "center")
love.graphics.setFont(font) love.graphics.printf(subtitle, 0, 300, love.graphics.getWidth(), "center")end
function love.draw() love.graphics.push() if shake > 0 then love.graphics.translate(math.random(-6, 6), math.random(-6, 6)) end
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1
if not blink then love.graphics.setColor(1, 1, 1) love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2) end
love.graphics.setColor(1, 0.9, 0.3) for _, b in ipairs(bullets) do love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16) end
for _, e in ipairs(enemies) do e:draw() end
love.graphics.pop()
drawHud()
if gameState == "menu" then drawOverlay("SKYNEST", "Press space to play") elseif gameState == "gameover" then drawOverlay("GAME OVER", "Score " .. score .. " - press space to play again") endendwrite wants a string, so tostring the number rather than leaning on coercion.-- main.lua
local Enemy = require("enemy")local collision = require("collision")
local shiplocal fontlocal titleFontlocal soundslocal highScorelocal spawnEvery = 0.8local gameState = "menu"
local playerlocal bulletslocal fireCooldownlocal enemieslocal spawnTimerlocal scorelocal liveslocal invulnlocal shake
local function resetGame() player = { x = 400, y = 500, size = 40, speed = 300 } bullets = {} fireCooldown = 0 enemies = {} spawnTimer = 0 score = 0 lives = 3 invuln = 0 shake = 0end
local function loadHighScore() local contents = love.filesystem.read("highscore.txt") return tonumber(contents) or 0end
function love.load() love.graphics.setBackgroundColor(0.05, 0.05, 0.1) math.randomseed(os.time()) ship = love.graphics.newImage("ship.png") font = love.graphics.newFont(20) titleFont = love.graphics.newFont(48) love.graphics.setFont(font)
sounds = { shoot = love.audio.newSource("shoot.wav", "static"), hit = love.audio.newSource("hit.wav", "static"), explode = love.audio.newSource("explode.wav", "static"), } sounds.shoot:setVolume(0.35) sounds.hit:setVolume(0.6)
highScore = loadHighScore() resetGame()end
function love.keypressed(key) if key == "escape" then love.event.quit() elseif key == "space" and gameState ~= "playing" then resetGame() gameState = "playing" endend
function love.update(dt) shake = shake - dt
if gameState ~= "playing" then return end
if love.keyboard.isDown("left", "a") then player.x = player.x - player.speed * dt end if love.keyboard.isDown("right", "d") then player.x = player.x + player.speed * dt end
local half = player.size / 2 player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt invuln = invuln - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 }) fireCooldown = 0.15 sounds.shoot:clone():play() end
for i = #bullets, 1, -1 do local b = bullets[i] b.y = b.y - b.speed * dt if b.y < -20 then table.remove(bullets, i) end end
spawnTimer = spawnTimer - dt if spawnTimer <= 0 then table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40))) spawnTimer = spawnEvery end
for i = #enemies, 1, -1 do local e = enemies[i] e:update(dt) if e.y > love.graphics.getHeight() + 20 then table.remove(enemies, i) end end
if invuln <= 0 then for i = #enemies, 1, -1 do if collision.overlaps(enemies[i], player) then table.remove(enemies, i) lives = lives - 1 invuln = 2 shake = 0.4 sounds.explode:play() if lives <= 0 then gameState = "gameover" if score > highScore then highScore = score love.filesystem.write("highscore.txt", tostring(highScore)) end end break end end end
for i = #enemies, 1, -1 do for j = #bullets, 1, -1 do if collision.overlaps(enemies[i], bullets[j]) then table.remove(bullets, j)
local thud = sounds.hit:clone() thud:setPitch(0.9 + math.random() * 0.3) thud:play()
enemies[i].hp = enemies[i].hp - 1 if enemies[i].hp <= 0 then table.remove(enemies, i) score = score + 1 end break end end endend
local function drawHud() love.graphics.setColor(1, 1, 1) love.graphics.print("Score: " .. score, 10, 12) love.graphics.print("Best: " .. highScore, 10, 36)
for i = 1, lives do local x = love.graphics.getWidth() - 24 - (i - 1) * 30 love.graphics.draw(ship, x, 24, 0, 1, 1, ship:getWidth() / 2, ship:getHeight() / 2) endend
local function drawOverlay(title, subtitle) love.graphics.setColor(0, 0, 0, 0.7) love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
love.graphics.setColor(1, 1, 1) love.graphics.setFont(titleFont) love.graphics.printf(title, 0, 220, love.graphics.getWidth(), "center")
love.graphics.setFont(font) love.graphics.printf(subtitle, 0, 300, love.graphics.getWidth(), "center")end
function love.draw() love.graphics.push() if shake > 0 then love.graphics.translate(math.random(-6, 6), math.random(-6, 6)) end
local blink = invuln > 0 and math.floor(invuln * 10) % 2 == 1
if not blink then love.graphics.setColor(1, 1, 1) love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2) end
love.graphics.setColor(1, 0.9, 0.3) for _, b in ipairs(bullets) do love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16) end
for _, e in ipairs(enemies) do e:draw() end
love.graphics.pop()
drawHud()
if gameState == "menu" then drawOverlay("SKYNEST", "Press space to play") elseif gameState == "gameover" then drawOverlay("GAME OVER", "Score " .. score .. " - best " .. highScore .. " - press space to play again") endendYour folder should hold exactly these eight files.
skynest/ main.lua conf.lua enemy.lua collision.lua ship.png shoot.wav hit.wav explode.wavcd — you are inside the folder zipping ., and the archive is written one level up so it does not try to include itself. The -x ".*" leaves out hidden files like .DS_Store.cd skynestzip -9 -r ../skynest.love . -x ".*"unzip -l skynest.love. Either way you want main.lua sitting at the top with no folder in front of it.highScore, so score == highScore is also true when you exactly tie an old record. Deriving state instead of storing it is usually right, and this is a small clean example of where it is not.loadstring. The second one is how a great many real games do it, and it is worth knowing that a save file being executable code is a decision with consequences.banana, and launch the game. It should start at zero without complaining, and if it does, you already wrote the error handling — go and find the or 0 that did it.love.filesystem.getSaveDirectory() tells you exactly where your file went, which is the fastest way to end an argument with yourself about whether it saved.
A cold launch on a machine that has played before. Best: 25 came off the disk in love.load, before anything was pressed.
If the best score never changes, look at whether you saved on the hit that ended the run or somewhere the early return can reach. If it resets to zero every launch, print love.filesystem.getSaveDirectory() and go look in that folder — either the file is not there, in which case the write never ran, or it is there with the right number in it, in which case the read is the one-liner and you are getting a base conversion.
You have a game that starts, is hard, can be lost, remembers you, and fits in one file you can send to somebody. It is built out of four Lua files and about two hundred lines, and there is nothing in it you did not write.
Look back at what is actually doing the work. The enemies are the metatable class from Activity 1.11. Both removal loops run backwards because of Activity 1.9. The cooldown, the spawn timer, the invulnerability and the shake are the same accumulator four times over. The HUD counts icons from a number rather than trusting a second copy of the truth. LÖVE never taught you any of that. It drew the pixels and told you which keys were down, and that was the whole of its contribution.
That was the point of doing Unit 1 first, and it is why you can now pick up any Lua codebase — a Roblox game, a Neovim config, a Redis script, a mod for something you like — and read it. Frameworks are the easy half. You did the other one.