Card Art, Sound and Localization in LOVE2D

Add AI card art, sound effects, and French/English support to a card game.

Introduction

30 Second Summary

Every card game has a moment where it stops feeling like a prototype and starts feeling like something real. That shift comes from three things: artwork that catches your eye, sounds that respond to your actions, and text that speaks your language.

In this project, you will polish Dinoblivion by adding AI-generated card art, sound effects, background music, and a French/English language toggle to your existing LOVE2D card game.

What You'll Build

A fully polished version of Dinoblivion with illustrated cards, a retro SFX library, looping background music, and a runtime language switcher built into the Settings menu.

By the end of this project, you'll have:

  • AI-generated card art for clan cards, action cards, dinosaurs, and enemies rendered on every card with a graceful fallback for missing images.
  • A full SFX library generated with jsfxr and a background music loop sourced from Pixabay, all wired into every major game action with volume controls.
  • A lightweight i18n system with French and English translation tables and a persistent language toggle in the Settings screen.
  • Secret Mission: Add smooth card movement animations with a tweening system and hover effects.

Are there any prerequisites?

This project is Part 3 of the Dinoblivion series. You need Parts 1 and 2 completed, with the lua_dinoB repository cloned, running, and ready to go on your machine.

Before We Start

Before We Start...

Organize the Asset Folder and Image Loading System

Right now, every card in Dinoblivion renders as a colored rectangle. That works for prototyping, but a polished game needs real artwork on every card.

In this step, you'll create the folder structure for all game assets, then build an image loading system in engine.lua that automatically discovers and caches card images. The system includes a fallback so cards without artwork keep their colored rectangles.

In this step, get ready to:
  • Create the asset folder structure for images, sounds, and music.
  • Build an image cache and loader function in engine.lua.
  • Update the card renderer to display images with a colored-rectangle fallback.
Create the asset folder structure

Game assets like images, sound effects, and music each need their own folder. Organizing them now means the loading functions you write next can find files automatically.

  • Open a terminal in your lua_dinoB project folder.
  • Create all four asset directories at once by running this command:
mkdir -p assets/images/cards assets/images/ui assets/sfx assets/music

What does this command do?

The -p flag creates parent directories as needed. One command creates the full tree: assets/images/cards/ for card art, assets/images/ui/ for interface graphics, assets/sfx/ for sound effects, and assets/music/ for background music.

  • Confirm the folders exist by running:
ls -R assets/

You should see the nested folder tree: images/cards, images/ui, music, and sfx listed under assets/. All directories are empty for now.

Don't see the folders?

Make sure your terminal is inside the lua_dinoB project folder, not a parent directory. Run pwd to check your current location.

Help me debug my asset folder structure.

Add an image cache and loader to engine.lua

Loading an image from disk every single frame would be extremely slow. The standard approach in LOVE2D is to load images once during startup and cache them in memory. Your loader function will scan the cards folder, load every PNG it finds, and store each image keyed by the card ID.

  • In VS Code, open engine.lua.
  • Find the line where engine is first declared (near the top of the file).
  • Add the image cache table and loader function below the existing engine setup by copying this code:
-- Image cache: maps card IDs to loaded LOVE2D Image objects
engine.images = {}

-- Scan the cards folder and load every PNG into the image cache
function engine.loadCardImages()
    local dir = "assets/images/cards"
    local items = love.filesystem.getDirectoryItems(dir)
    for _, filename in ipairs(items) do
        if filename:match("%.png$") then
            -- Strip the .png extension to get the card ID
            local id = filename:gsub("%.png$", "")
            engine.images[id] = love.graphics.newImage(dir .. "/" .. filename)
        end
    end
end

What does this code do?

  • engine.images is an empty table that acts as a cache. Card IDs become the keys, and loaded Image objects become the values.
  • love.filesystem.getDirectoryItems() returns a list of every filename inside a directory. This lets the loader auto-discover images instead of requiring a hardcoded list.
  • The :match("%.png$") filter ensures only PNG files are loaded. The :gsub() call strips the extension so a file named bananar_sun.png becomes the key bananar_sun, matching the card ID in card_db.lua.
  • love.graphics.newImage() loads the file from disk into GPU memory once. After that, drawing it each frame is fast.

The loader function is ready, but nothing calls it yet. You need to trigger it when the game starts.

  • Open main.lua.
  • Find the love.load() function.
  • Add this line inside love.load(), after the existing setup calls:
-- Load card artwork from the assets folder
engine.loadCardImages()
  • Save both engine.lua and main.lua.

You won't see a change yet. The loader runs at startup, but the cards folder is empty so nothing gets cached. The next substep updates the card renderer to use this cache.

Getting an error when the game starts?

If you see a "module not found" or nil error, make sure you placed engine.loadCardImages() inside the love.load() function body, not above or below it.

Also check that the engine.images = {} line is placed after the initial engine table declaration in engine.lua, not before it.

Help me fix my image loader error in LOVE2D.

Update engine.drawCard() to render images

The image cache is loaded at startup. Now the card renderer needs to check it. If an image exists for a card, the renderer draws it scaled to fit. If not, it falls back to the existing colored rectangle.

  • In engine.lua, find the engine.drawCard() function.
  • Find the lines where the colored rectangle is drawn (the love.graphics.setColor() and love.graphics.rectangle("fill", ...) lines).
  • Replace those rectangle-drawing lines with this image-or-fallback block:
-- Check if a card image exists in the cache
local img = engine.images[card.id]
if img then
    -- Scale the image to fit the card dimensions
    local scaleX = engine.CARD_W / img:getWidth()
    local scaleY = engine.CARD_H / img:getHeight()
    love.graphics.setColor(1, 1, 1)
    love.graphics.draw(img, x, y, 0, scaleX, scaleY)
else
    -- No image found: fall back to colored rectangle
    love.graphics.setColor(card.color or {0.3, 0.3, 0.3})
    love.graphics.rectangle("fill", x, y, engine.CARD_W, engine.CARD_H)
end

What does this code do?

  • engine.images[card.id] looks up the cache. If a PNG was loaded for this card ID, img holds the Image object. Otherwise it's nil.
  • The scale factors divide the card dimensions (90x130) by the image's actual pixel dimensions. This shrinks or stretches the image to fit exactly inside the card.
  • love.graphics.setColor(1, 1, 1) resets the draw color to white before drawing the image. Without this, the image would be tinted by whatever color was set previously.
  • The else branch preserves the original colored-rectangle rendering. Cards without artwork still look exactly the same as before.

Why keep the text overlays?

The existing text overlays (card name, stats, cost) that are drawn after the rectangle should remain untouched. They draw on top of whatever background the card has, whether that's an image or a colored rectangle. Do not delete or move those lines.

  • Save engine.lua.
  • Run the game from your terminal by running this command:
love .

You should see the game start normally with all cards still showing as colored rectangles. The image loading system is active, but the assets/images/cards/ folder is empty so every card hits the fallback renderer.

Game crashes on startup?

Check that the if img then ... else ... end block fully replaced the old rectangle-drawing lines. A common mistake is leaving a duplicate love.graphics.rectangle() call outside the if/else block.

If you see attempt to index a nil value, make sure engine.images = {} appears before any function that references it.

Help me debug my card renderer in LOVE2D.

✔️ Awesome, I've got everything!

Great. Double check you've saved both engine.lua and main.lua.

ⓧ I'd like to double check the full code

Here are the sections you added or modified. Compare these against your files.

In engine.lua, you added the image cache table and loader function:

-- Image cache: maps card IDs to loaded LOVE2D Image objects
engine.images = {}

-- Scan the cards folder and load every PNG into the image cache
function engine.loadCardImages()
    local dir = "assets/images/cards"
    local items = love.filesystem.getDirectoryItems(dir)
    for _, filename in ipairs(items) do
        if filename:match("%.png$") then
            -- Strip the .png extension to get the card ID
            local id = filename:gsub("%.png$", "")
            engine.images[id] = love.graphics.newImage(dir .. "/" .. filename)
        end
    end
end

In engine.drawCard(), you replaced the colored-rectangle drawing with this image-or-fallback block:

-- Check if a card image exists in the cache
local img = engine.images[card.id]
if img then
    -- Scale the image to fit the card dimensions
    local scaleX = engine.CARD_W / img:getWidth()
    local scaleY = engine.CARD_H / img:getHeight()
    love.graphics.setColor(1, 1, 1)
    love.graphics.draw(img, x, y, 0, scaleX, scaleY)
else
    -- No image found: fall back to colored rectangle
    love.graphics.setColor(card.color or {0.3, 0.3, 0.3})
    love.graphics.rectangle("fill", x, y, engine.CARD_W, engine.CARD_H)
end

In main.lua, you added this line inside love.load():

-- Load card artwork from the assets folder
engine.loadCardImages()

Your image loading system is built and ready. Next up, you'll generate actual card art with AI and drop them into the folders you just created.

Generate Card Art with Microsoft Designer

Your image loading system is built and ready to go. Right now every card still shows a colored rectangle because the assets/images/cards/ folder is empty.

In this step, you will generate actual card illustrations using Microsoft Designer, a free AI image generator. Once you save them with the right filenames, the engine picks them up automatically and renders them on every card.

In this step, get ready to:
  • Generate AI card illustrations for all major card types in Dinoblivion.
  • Resize and save images with filenames that match the card IDs in your game.
  • Verify that artwork renders in-game with the fallback system still working for unmatched cards.
Generate card art for your first 6 cards

Microsoft Designer (also known as Bing Image Creator) lets you generate images from a text prompt for free. You get roughly 15 fast generations per day, which is more than enough for a deck of cards.

You will generate one image per card using a consistent prompt structure. Every prompt follows this base template:

2D card game illustration, prehistoric [subject], stylized, vibrant colors, dark background, no text

Why 'no text' in the prompt?

AI image generators often produce garbled or misspelled text when asked to include words. Your game already draws card names and stats as text overlays, so the illustration only needs to be artwork.

Start by generating images for these 6 cards. Replace the bracketed subject in the template with the description for each card:

  • bananar_sun: a banana-wielding primitive warrior.
  • explorar_sun: a prehistoric scout carrying a torch.
  • fructam_sun: a fruit gatherer in a jungle.
  • totem_feu: a carved stone totem with fire symbols in a prehistoric setting.
  • raptor: a raptor dinosaur.
  • trex: a T-Rex dinosaur.

For each card:

  • Paste the base prompt into the text field, replacing the subject with the card description above.
  • Click Create.
  • Microsoft Designer generates 4 image variations. Pick the one that best fits a vertical card shape.
  • Click the image, then click Download to save it to your Downloads folder.

Pick images with a vertical composition

Microsoft Designer outputs square images, but your cards are tall and narrow (180x260). Images where the subject is centered and vertical will look best after resizing. Avoid images with important detail at the left and right edges.

Resize and place images in your project

The image loading system in engine.lua matches PNG filenames to card IDs. For a card with the ID bananar_sun, the engine looks for bananar_sun.png. If the filename does not match exactly, the card falls back to a colored rectangle.

Each image needs to be resized to 180x260 pixels. That is 2x the card's on-screen size (90x130) so the art looks crisp on the virtual canvas.

  • Open each downloaded image in your preferred image editor (GIMP, Shotwell, or any tool that can resize).
  • Resize the image to 180 pixels wide by 260 pixels tall.
  • Export or save it as a PNG file.

Batch resize from the terminal

If you have ImageMagick installed, you can resize all 6 images at once. Place the downloaded images in a temporary folder and run:

for img in *.png; do
  magick "$img" -resize 180x260! "$img"
done

The ! after the dimensions forces the exact size, ignoring the original aspect ratio.

  • Save or move each resized image into assets/images/cards/ inside your lua_dinoB project folder.
  • Name each file to match its card ID exactly:
  • bananar_sun.png
  • explorar_sun.png
  • fructam_sun.png
  • totem_feu.png
  • raptor.png
  • trex.png

Why do filenames have to match card IDs?

The engine.loadCardImages() function you built in Step 1 scans the assets/images/cards/ folder, strips the .png extension from each filename, and uses the result as the lookup key.

When engine.drawCard() runs, it checks engine.images[card.id]. If the filename does not match the ID in card_db.lua, the lookup returns nil and the card falls back to a colored rectangle.

  • Confirm the files are in place by running this command from your project folder:
ls assets/images/cards/

You should see 6 PNG files listed: bananar_sun.png, explorar_sun.png, fructam_sun.png, totem_feu.png, raptor.png, and trex.png.

  • Run the game to verify your artwork loads:
love .

Start a new game and look at the cards in your hand and on the board. The 6 cards you created art for should now display illustrations instead of colored rectangles. Other cards still show the rectangle fallback.

Still seeing colored rectangles for cards that have art?

Double check that each filename matches the card ID exactly, including underscores and lowercase letters. For example, Bananar_Sun.png will not match the ID bananar_sun because Lua string comparisons are case-sensitive.

Also make sure the files are inside assets/images/cards/ relative to your project root (the folder where main.lua lives), not in a subfolder.

Help me debug why my card images aren't loading in LOVE2D.

Generate art for the remaining cards

Six cards down, four to go. The remaining cards cover an enemy, a chief, an ally, and the Dinoblivion card itself.

  • Go back to Microsoft Designer and generate one image for each of these cards using the same prompt template:
  • piranar: a fierce piranha-like prehistoric creature.
  • chief_bobor: a prehistoric tribal chief wearing a headdress.
  • ami_sun: a friendly prehistoric ally character.
  • dinoblivion_sun: a mysterious oblivion card with swirling prehistoric energy.
  • Download each image, resize it to 180x260 pixels, and save it into assets/images/cards/ with the matching filename:
  • piranar.png
  • chief_bobor.png
  • ami_sun.png
  • dinoblivion_sun.png
  • Verify all 10 files are in place by running:
ls assets/images/cards/

You should see 10 PNG files listed.

  • Run the game one more time to confirm all major card types display artwork:
love .

Start a new game and look through your hand, the board, and the shop. Every major card type (clan cards, dinosaurs, enemies, totem, chief, ally, and Dinoblivion) should now show illustrations. Any cards that do not have a matching image file still fall back to the colored-rectangle style.

What about cards that share art between Sun and Moon clans?

Some cards exist in both clans with different IDs (like bananar_sun and bananar_moon). If you want both to show the same art, duplicate the file with the other clan's name. For example, copy bananar_sun.png to bananar_moon.png.

Some cards show art but others don't?

Check the terminal output when the game starts. The engine.loadCardImages() function only loads files ending in .png. If you accidentally saved a file as .jpg or .PNG (uppercase), it will not be picked up.

Help me figure out why some card images load but others don't in my LOVE2D game.

Your cards have real artwork now. Next up, you will generate sound effects and background music to make the game feel alive.

Add Sound Effects and Background Music

Your cards already look the part with AI-generated artwork. Now it is time to make the game sound like a real card game too.

In this step, you will generate retro sound effects using jsfxr, download a background music loop from Pixabay, and wire everything into the existing audio.lua scaffold so every game action has an audio cue.

In this step, get ready to:
  • Generate 6 retro sound effects with jsfxr and download a background music loop.
  • Implement the audio loading and playback system in scripts/audio.lua.
  • Wire Audio.playSfx() calls into main.lua so every major game action triggers a sound.
Generate SFX with jsfxr

jsfxr is a browser-based tool built specifically for generating retro game sound effects. It offers preset categories that give you a starting point, then lets you tweak parameters until the sound fits.

Why jsfxr over a sound library?

jsfxr (sfxr.me) is unlimited, free, and exports WAV files you can use commercially. Other tools like ElevenLabs or Adobe Firefly have credit limits and unclear commercial rights for game assets.

Generating your own SFX also means you can fine-tune every sound to match the feel of your game.

You will see a row of preset buttons along the top (Pickup/Coin, Laser/Shoot, Explosion, etc.) and a waveform display in the center.

  • Generate and download each of these 6 sounds, one at a time. For each sound, click the named preset button, tweak the parameters until you like the result, then click the Export as .wav button to download it.
  • Card play sound: click Pickup/Coin. Save as card_play.wav.
  • Attack hit sound: click Hit/Hurt. Save as attack.wav.
  • RAGE alert sound: click Explosion, then lower the frequency slider for a deeper rumble. Save as rage.wav.
  • Hunt reveal sound: click Blip/Select. Save as hunt.wav.
  • Buy/purchase sound: click Powerup. Save as buy.wav.
  • UI click sound: click Blip/Select again, but keep it short and subtle. Save as click.wav.
  • Move all 6 downloaded WAV files into your assets/sfx/ folder inside your lua_dinoB project directory.
  • Confirm the files are in place by running:
ls assets/sfx/

You should see attack.wav, buy.wav, card_play.wav, click.wav, hunt.wav, and rage.wav listed in the output.

Don't see all 6 files?

Check that you moved the files into assets/sfx/ inside your lua_dinoB project, not a different folder. Also double-check the filenames match exactly (all lowercase, underscores, .wav extension).

Your browser may have renamed duplicates (e.g. click (1).wav). Rename them to remove the suffix.

Help me figure out why my SFX files aren't showing up in assets/sfx/

Download background music from Pixabay

Background music sets the atmosphere for your card game. LOVE2D loops audio seamlessly when the file is in OGG format, so that is the format you need.

Why Pixabay over AI music generators?

AI music generators like Suno and Udio do not grant commercial rights on their free tiers. Pixabay's license explicitly allows commercial and non-commercial use without attribution. A pre-made loop download is also faster than AI generation for a single background track.

  • Open pixabay.com/music in your browser.
  • Search for prehistoric ambient loop or tribal ambient. Pick a track that feels like a prehistoric setting.
  • Click the Download button on the track you chose.
  • If the downloaded file is in MP3 format, convert it to OGG using an online converter (search for "MP3 to OGG converter") or a command-line tool like ffmpeg.
  • Save or move the final OGG file into assets/music/ as bgm.ogg.
  • Confirm the file is in place by running:
ls assets/music/

You should see bgm.ogg in the output.

OGG is essential for seamless loops

LOVE2D can play MP3 files, but OGG handles loop points without the tiny gap that MP3 introduces at the start of the file. For background music that loops forever, OGG keeps the transition seamless.

File downloaded as MP3?

If you have ffmpeg installed, convert it from your terminal with: ffmpeg -i input.mp3 bgm.ogg. Otherwise, any free online MP3-to-OGG converter works.

Help me convert my Pixabay download to OGG format for LOVE2D

Wire audio files into scripts/audio.lua

Your scripts/audio.lua scaffold already has function definitions with TODO markers inside. Now you will replace the scaffold with real loading and playback code that connects to your new audio files.

  • Open scripts/audio.lua in VS Code.
  • Select all the existing scaffold code and replace it with this module header and Audio.init() function:
local Audio = {}

local sounds = {}
local musicFade = 1.0
local fadingOut = false
local musicVolume = 1.0
local sfxVolume = 0.7

function Audio.init(settings)
    musicVolume = settings.volume
    sfxVolume = settings.sfxVolume or 0.7

    -- Background music (OGG for seamless looping)
    if love.filesystem.getInfo("assets/music/bgm.ogg") then
        sounds.music = love.audio.newSource("assets/music/bgm.ogg", "stream")
        sounds.music:setLooping(true)
    end

    -- Sound effects (WAV for low-latency playback)
    local sfx_files = {
        click = "assets/sfx/click.wav",
        card_play = "assets/sfx/card_play.wav",
        attack = "assets/sfx/attack.wav",
        rage = "assets/sfx/rage.wav",
        hunt = "assets/sfx/hunt.wav",
        buy = "assets/sfx/buy.wav",
    }

    for name, path in pairs(sfx_files) do
        if love.filesystem.getInfo(path) then
            sounds[name] = love.audio.newSource(path, "static")
        end
    end
end

What does this code do?

The local variables at the top track the current volume levels and fade state. The sounds table acts as a central registry for every loaded audio source.

Inside Audio.init(), music is loaded with "stream" type because it is a large file that should be decoded on the fly. SFX use "static" type because they are short clips that load entirely into memory for instant playback.

Every file load is wrapped in a love.filesystem.getInfo() check. If a file is missing, the game still runs. It just skips that sound instead of crashing.

  • Below the Audio.init() function, add the playback and control functions:
function Audio.playMusic()
    if sounds.music and not sounds.music:isPlaying() then
        sounds.music:setVolume(musicVolume)
        sounds.music:play()
    end
end

function Audio.stopMusic()
    if sounds.music then sounds.music:stop() end
end

function Audio.playSfx(name)
    if sounds[name] then
        -- Clone the source so overlapping sounds work
        local s = sounds[name]:clone()
        s:setVolume(sfxVolume)
        s:play()
    end
end

function Audio.setSfxVolume(v)
    sfxVolume = v
end

function Audio.setMusicVolume(v)
    musicVolume = v
    if sounds.music then sounds.music:setVolume(musicVolume) end
end

function Audio.fadeOutMusic()
    fadingOut = true
end

function Audio.update(dt)
    if fadingOut then
        -- Fade volume to zero over ~2 seconds
        musicFade = math.max(0, musicFade - dt / 2)
        if sounds.music then
            sounds.music:setVolume(musicFade * musicVolume)
        end
        if musicFade <= 0 then
            fadingOut = false
            musicFade = 1.0
            Audio.stopMusic()
        end
    end
end

return Audio

What does this code do?

  • Audio.playMusic() starts the background track if it is not already playing. Audio.stopMusic() stops it immediately.
  • Audio.playSfx(name) clones the source before playing. This is key. Without cloning, playing the same sound twice in quick succession would cut off the first one.
  • Audio.fadeOutMusic() triggers a gradual fade handled inside Audio.update(dt). The volume decreases each frame until it reaches zero, then the music stops cleanly.
  • The volume setter functions (setSfxVolume, setMusicVolume) let the Settings screen sliders control audio levels in real time.
  • Save scripts/audio.lua.

Seeing a syntax error on launch?

Check that the string paths inside sfx_files match your actual filenames exactly (case-sensitive on Linux). A typo in the filename string will not crash, but a missing comma or quote will.

Also verify the file ends with return Audio. Without it, require("scripts.audio") returns true instead of the module table.

Help me debug a syntax error in my audio.lua file

✔️ Awesome, I've got everything!

Great. Double check you have saved scripts/audio.lua before moving on.

ⓧ I'd like to double check the full code

Here is the complete scripts/audio.lua file. Make sure yours matches this exactly.

local Audio = {}

local sounds = {}
local musicFade = 1.0
local fadingOut = false
local musicVolume = 1.0
local sfxVolume = 0.7

function Audio.init(settings)
    musicVolume = settings.volume
    sfxVolume = settings.sfxVolume or 0.7

    -- Background music (OGG for seamless looping)
    if love.filesystem.getInfo("assets/music/bgm.ogg") then
        sounds.music = love.audio.newSource("assets/music/bgm.ogg", "stream")
        sounds.music:setLooping(true)
    end

    -- Sound effects (WAV for low-latency playback)
    local sfx_files = {
        click = "assets/sfx/click.wav",
        card_play = "assets/sfx/card_play.wav",
        attack = "assets/sfx/attack.wav",
        rage = "assets/sfx/rage.wav",
        hunt = "assets/sfx/hunt.wav",
        buy = "assets/sfx/buy.wav",
    }

    for name, path in pairs(sfx_files) do
        if love.filesystem.getInfo(path) then
            sounds[name] = love.audio.newSource(path, "static")
        end
    end
end

function Audio.playMusic()
    if sounds.music and not sounds.music:isPlaying() then
        sounds.music:setVolume(musicVolume)
        sounds.music:play()
    end
end

function Audio.stopMusic()
    if sounds.music then sounds.music:stop() end
end

function Audio.playSfx(name)
    if sounds[name] then
        local s = sounds[name]:clone()
        s:setVolume(sfxVolume)
        s:play()
    end
end

function Audio.setSfxVolume(v)
    sfxVolume = v
end

function Audio.setMusicVolume(v)
    musicVolume = v
    if sounds.music then sounds.music:setVolume(musicVolume) end
end

function Audio.fadeOutMusic()
    fadingOut = true
end

function Audio.update(dt)
    if fadingOut then
        musicFade = math.max(0, musicFade - dt / 2)
        if sounds.music then
            sounds.music:setVolume(musicFade * musicVolume)
        end
        if musicFade <= 0 then
            fadingOut = false
            musicFade = 1.0
            Audio.stopMusic()
        end
    end
end

return Audio

The Audio module is ready. Now you need to call it from main.lua so sounds actually play during gameplay.

  • Open main.lua in VS Code.
  • Find the place where background music should start (inside or just after love.load(), after Audio.init() is called). Add a call to Audio.playMusic() so the background track starts as soon as the game loads.
  • Add Audio.playSfx("card_play") wherever a clan card is activated (the code path that handles clan card clicks).
  • Add Audio.playSfx("attack") inside the attackCreature() function, right after a successful kill is confirmed.
  • Add Audio.playSfx("rage") where the RAGE mechanic triggers.
  • Add Audio.playSfx("hunt") after a hunt action resolves.
  • Add Audio.playSfx("buy") inside buyCard() when a purchase succeeds.
  • Add Audio.playSfx("click") wherever UI buttons (menu items, settings options, end-turn button) are clicked.

Where exactly do I put each call?

Search your main.lua for function names like attackCreature and buyCard using Ctrl+Shift+F in VS Code. Each function has a clear success path (e.g. the line that prints a status message or updates the score). Place the Audio.playSfx() call right next to the existing success logic.

The Settings screen already has volume sliders that update Settings.volume and Settings.sfxVolume. You need to connect those sliders to the Audio module.

  • Find the code in main.lua where the music volume slider value changes. Add a call to Audio.setMusicVolume(Settings.volume) right after the slider value updates.
  • Find where the SFX volume slider value changes. Add a call to Audio.setSfxVolume(Settings.sfxVolume) right after that slider value updates.
  • Save main.lua.
  • Run the game by running:
love .

You should hear background music start immediately. Click through the menu and start a game. Playing a clan card, attacking a creature, buying from the market, and hunting should each trigger a distinct sound effect.

  • Open Settings from the main menu.
  • Drag the music volume slider left and right. The background music volume should change in real time.
  • Drag the SFX volume slider. Go back to the game and click something. The sound effect volume should match your new setting.

No sound at all?

First check that your system volume is not muted. Then verify the filenames in assets/sfx/ match the keys in the sfx_files table exactly (case-sensitive on Linux).

If background music does not play, check that bgm.ogg is actually an OGG file and not an MP3 with a renamed extension. LOVE2D will silently fail if the format does not match the extension.

Help me troubleshoot why no audio is playing in my LOVE2D game

Music plays but SFX don't?

Make sure you added the Audio.playSfx() calls in the correct locations in main.lua. The calls must be inside the success path of each function, not in a branch that never runs. Add a temporary print("SFX triggered") line next to each Audio.playSfx() call. If you see the print but hear nothing, the issue is the audio file itself.

Help me figure out why SFX are not playing even though music works in my LOVE2D game

Your game now looks and sounds like a real card game. Next up, you will make it speak two languages by building an internationalization system that lets players switch between French and English from the Settings menu.

Build the Internationalization System

Your game now sounds great with retro SFX firing on every action and an atmospheric loop playing in the background. The next layer of polish is making Dinoblivion accessible to players who speak different languages.

In this step, you will build a lightweight internationalization (i18n) system using Lua tables. A simple T(key) function looks up translated strings at runtime, and a language toggle in the Settings menu lets the player switch between French and English.

In this step, get ready to:
  • Create the i18n.lua module and French/English locale files.
  • Replace all hard-coded French strings with i18n.T() lookup calls.
  • Add a language toggle to the Settings screen that persists across sessions.
Create i18n.lua and locale files

The i18n system has three parts: a core module that looks up translated strings, and two locale files (one per language) that hold the actual translations. You will create all three files now.

  • Create the locales directory inside your lua_dinoB project folder by running this command:
mkdir -p locales
  • Confirm the directory was created by running:
ls -d locales

You should see locales printed in the output.

Now you will create the core i18n module. This is the brains of the translation system.

  • In VS Code, create a new file called i18n.lua in the root of your lua_dinoB folder (next to main.lua).
  • Copy the following code into i18n.lua:
local i18n = {}

local current_locale = "fr"
local translations = {}

-- Load a locale file from locales/<locale>.lua
function i18n.loadLocale(locale)
    local ok, tbl = pcall(require, "locales." .. locale)
    if ok and type(tbl) == "table" then
        translations[locale] = tbl
    else
        print("[i18n] Could not load locale: " .. locale)
        translations[locale] = {}
    end
end

-- Switch the active language
function i18n.setLocale(locale)
    current_locale = locale
    if not translations[locale] then
        i18n.loadLocale(locale)
    end
end

-- Return the current locale code ("fr" or "en")
function i18n.getLocale()
    return current_locale
end

-- Look up a translated string by key
function i18n.T(key, fallback)
    local tbl = translations[current_locale]
    if tbl and tbl[key] then
        return tbl[key]
    end
    return fallback or key
end

-- Shortcut: callable as i18n("key")
setmetatable(i18n, {
    __call = function(_, key, fallback)
        return i18n.T(key, fallback)
    end
})

-- Load both locales on require
i18n.loadLocale("fr")
i18n.loadLocale("en")

return i18n

What does this code do?

This module is a central translation lookup system. Here is how each piece works:

  • translations is a table that stores one sub-table per language. Each sub-table maps string keys (like menu_play) to translated text.
  • i18n.T(key) looks up the key in the current locale's table. If no translation is found, it returns the raw key as a fallback so missing strings are visible during testing.
  • The setmetatable trick lets you call i18n("key") as a shorthand for i18n.T("key").
  • Both locales are loaded immediately when the module is first required, so switching languages later is instant.

Why a custom module instead of a library?

Full i18n libraries like smiti18n exist for LOVE2D, but they add hundreds of lines of code you do not need. Your game only supports two languages.

A 30-line custom module teaches the core i18n pattern (key-based lookup with fallback) without an external dependency. This is the same T(key) pattern that the LOVE2D community recommends for lightweight localization.

  • Save i18n.lua.

Seeing a syntax error?

  • Make sure i18n.lua is in the project root alongside main.lua. If it is inside scripts/ or locales/, the require path will not resolve.
  • Check that the setmetatable block at the bottom has matching parentheses and curly braces. A missing }) will cause a parse error.
  • Why is my i18n module not loading?

Next, you will create the French translation table. This file holds every user-facing string in the game.

  • Create a new file called fr.lua inside the locales/ directory.
  • Copy the following translation table into locales/fr.lua:
return {
    -- Splash
    splash_title = "DINO HUNT",
    splash_skip = "Appuie sur Espace pour passer",

    -- Menu
    menu_title = "DINO HUNT",
    menu_play = "Jouer",
    menu_settings = "Paramètres",
    menu_quit = "Quitter",
    menu_nav = "HAUT/BAS : Naviguer ENTRÉE : Sélectionner Cliquer",

    -- Settings
    settings_title = "PARAMÈTRES",
    settings_volume = "Volume musique",
    settings_sfx = "Volume effets",
    settings_fullscreen_label = "Plein écran",
    settings_language_label = "Langue",
    settings_controls = "Contrôles",
    settings_back = "Retour",
    settings_on = "ON",
    settings_off = "OFF",
    settings_nav = "GAUCHE/DROITE : Ajuster ENTRÉE : Confirmer ÉCHAP : Retour",
    lang_fr = "Français",
    lang_en = "English",

    -- Setup
    setup_title = "NOUVELLE PARTIE",
    setup_difficulty = "DIFFICULTÉ",
    setup_clan = "CLAN",
    setup_clan_sun = "SOLEIL",
    setup_clan_moon = "LUNE",
    setup_launch = "LANCER LA PARTIE",
    setup_cards = "cartes",
    setup_nav = "HAUT/BAS : naviguer GAUCHE/DROITE : choisir ENTRÉE : confirmer ÉCHAP : retour",

    -- Gameplay
    turn_label = "Tour",
    score_label = "Score",
    strength_label = "Force",
    end_turn_btn = "Fin de Tour",
    atk_label = "ATK",
    cave_empty = "Vide",
    cave_one = "1 carte",
    cave_label = "Grotte",
    totem_label = "Totems",
    egg_label = "Oeufs",
    enemies_label = "Ennemis",
    remaining = "restant",
    remaining_plural = "restants",

    -- Actions
    activate_title = "Activer",
    action_power = "Pouvoir",
    action_support = "Activer Action",
    action_hunt_left = "Chasser Gauche",
    action_hunt_right = "Chasser Droite",
    action_attack_dino = "Attaquer Dino",
    action_form_ami = "Former Ami",
    choose_action_card = "Choisir une carte Action:",
    choose_destroy = "Choisir une carte à détruire :",
    pair_with = "Paire avec",
    support_cancelled = "Support annulé.",
    pair_cancelled = "Paire annulée.",

    -- Messages
    msg_clan_only = "Seules les cartes Clan peuvent être activées.",
    msg_hunt_result = "%d carte(s) chassées à %s, +%d nourriture",
    msg_action_activated = "Action activée: %s",
    msg_action_pending = "Action en cours: %s",
    msg_cave_swap = "Grotte: échange %s <-> %s",
    msg_cave_stored = "Grotte: %s stockée.",
    msg_cave_select = "Sélectionnez d'abord une carte en main.",
    msg_totem_activated = "Totem activé.",
    msg_not_for_sale = "%s n'est pas à vendre",
    msg_bought = "%s acheté ! Ajouté à la défausse.",
    msg_need_resource = "Il faut %d %s pour acheter %s",
    msg_defeated = "%s vaincu !",
    msg_need_atk = "%s : il faut %d ATK. Vous avez %d",
    msg_turn = "Tour %d",
    msg_new_turn = "Tour %d — Cliquez une carte Clan pour agir",

    -- Game over
    victory_header = "VICTOIRE !",
    defeat_header = "DÉFAITE !",
    final_score = "Score final : %d pts en %d tours",
    replay_prompt = "R — Rejouer | ÉCHAP — Menu",
    score_families = "Familles : %d x 2VP = %d VP",
    score_totems = "Totems : %d x 1VP = %d VP",
    score_dinos = "Dinosaures : %d x 1VP = %d VP",
    score_eggs = "Oeufs : %d VP",
    score_total = "TOTAL : %d VP",

    -- RAGE
    rage_text = "RAGE !",

    -- Name entry
    name_congrats = "FÉLICITATIONS !",
    name_score = "Score : %d Tours : %d",
    name_prompt = "Entrez vos initiales :",
    name_nav = "GAUCHE/DROITE : case HAUT/BAS : lettre Taper A-Z ENTRÉE : confirmer",

    -- High scores
    hs_title = "MEILLEURS SCORES",
    hs_rank = "#",
    hs_name = "NOM",
    hs_score = "SCORE",
    hs_empty = "Aucun score enregistré",
    hs_continue = "Appuie sur une touche ou clique pour continuer",

    -- Controls
    ctrl_title = "CONTRÔLES",
    ctrl_action = "ACTION",
    ctrl_key = "TOUCHE",
    ctrl_reset = "Réinitialiser",
    ctrl_back = "Retour",
    ctrl_waiting = "Appuie sur une touche... ÉCHAP = annuler",
    ctrl_nav = "HAUT/BAS : Naviguer ENTRÉE : Assigner ÉCHAP : Retour Cliquer pour assigner",
    ctrl_up = "Haut",
    ctrl_down = "Bas",
    ctrl_left = "Gauche",
    ctrl_right = "Droite",
    ctrl_jump = "Saut",
    ctrl_attack = "Attaque",
}

What does this file do?

This file returns a single Lua table. Each key (like menu_play) maps to the French text your game currently displays as a hard-coded string.

Keys that contain %d or %s are format templates. You will use string.format(i18n.T("key"), value) to fill in the blanks at runtime.

  • Save locales/fr.lua.

Now create the English translation table with matching keys.

  • Create a new file called en.lua inside the locales/ directory.
  • Copy the following translation table into locales/en.lua:
return {
    -- Splash
    splash_title = "DINO HUNT",
    splash_skip = "Press Space to skip",

    -- Menu
    menu_title = "DINO HUNT",
    menu_play = "Play",
    menu_settings = "Settings",
    menu_quit = "Quit",
    menu_nav = "UP/DOWN: Navigate ENTER: Select Click",

    -- Settings
    settings_title = "SETTINGS",
    settings_volume = "Music volume",
    settings_sfx = "SFX volume",
    settings_fullscreen_label = "Fullscreen",
    settings_language_label = "Language",
    settings_controls = "Controls",
    settings_back = "Back",
    settings_on = "ON",
    settings_off = "OFF",
    settings_nav = "LEFT/RIGHT: Adjust ENTER: Confirm ESC: Back",
    lang_fr = "Français",
    lang_en = "English",

    -- Setup
    setup_title = "NEW GAME",
    setup_difficulty = "DIFFICULTY",
    setup_clan = "CLAN",
    setup_clan_sun = "SUN",
    setup_clan_moon = "MOON",
    setup_launch = "START GAME",
    setup_cards = "cards",
    setup_nav = "UP/DOWN: navigate LEFT/RIGHT: choose ENTER: confirm ESC: back",

    -- Gameplay
    turn_label = "Turn",
    score_label = "Score",
    strength_label = "Strength",
    end_turn_btn = "End Turn",
    atk_label = "ATK",
    cave_empty = "Empty",
    cave_one = "1 card",
    cave_label = "Cave",
    totem_label = "Totems",
    egg_label = "Eggs",
    enemies_label = "Enemies",
    remaining = "remaining",
    remaining_plural = "remaining",

    -- Actions
    activate_title = "Activate",
    action_power = "Power",
    action_support = "Support Action",
    action_hunt_left = "Hunt Left",
    action_hunt_right = "Hunt Right",
    action_attack_dino = "Attack Dino",
    action_form_ami = "Form Ally",
    choose_action_card = "Choose an Action card:",
    choose_destroy = "Choose a card to destroy:",
    pair_with = "Pair with",
    support_cancelled = "Support cancelled.",
    pair_cancelled = "Pair cancelled.",

    -- Messages
    msg_clan_only = "Only Clan cards can be activated.",
    msg_hunt_result = "%d card(s) hunted %s, +%d food",
    msg_action_activated = "Action activated: %s",
    msg_action_pending = "Action in progress: %s",
    msg_cave_swap = "Cave: swapped %s <-> %s",
    msg_cave_stored = "Cave: %s stored.",
    msg_cave_select = "Select a hand card first.",
    msg_totem_activated = "Totem activated.",
    msg_not_for_sale = "%s is not for sale",
    msg_bought = "%s bought! Added to discard.",
    msg_need_resource = "Need %d %s to buy %s",
    msg_defeated = "%s defeated!",
    msg_need_atk = "%s: need %d ATK. You have %d",
    msg_turn = "Turn %d",
    msg_new_turn = "Turn %d — Click a Clan card to act",

    -- Game over
    victory_header = "VICTORY!",
    defeat_header = "DEFEAT!",
    final_score = "Final score: %d pts in %d turns",
    replay_prompt = "R — Replay | ESC — Menu",
    score_families = "Families: %d x 2VP = %d VP",
    score_totems = "Totems: %d x 1VP = %d VP",
    score_dinos = "Dinosaurs: %d x 1VP = %d VP",
    score_eggs = "Eggs: %d VP",
    score_total = "TOTAL: %d VP",

    -- RAGE
    rage_text = "RAGE!",

    -- Name entry
    name_congrats = "CONGRATULATIONS!",
    name_score = "Score: %d Turns: %d",
    name_prompt = "Enter your initials:",
    name_nav = "LEFT/RIGHT: slot UP/DOWN: letter Type A-Z ENTER: confirm",

    -- High scores
    hs_title = "HIGH SCORES",
    hs_rank = "#",
    hs_name = "NAME",
    hs_score = "SCORE",
    hs_empty = "No scores recorded",
    hs_continue = "Press any key or click to continue",

    -- Controls
    ctrl_title = "CONTROLS",
    ctrl_action = "ACTION",
    ctrl_key = "KEY",
    ctrl_reset = "Reset",
    ctrl_back = "Back",
    ctrl_waiting = "Press a key... ESC = cancel",
    ctrl_nav = "UP/DOWN: Navigate ENTER: Assign ESC: Back Click to assign",
    ctrl_up = "Up",
    ctrl_down = "Down",
    ctrl_left = "Left",
    ctrl_right = "Right",
    ctrl_jump = "Jump",
    ctrl_attack = "Attack",
}

What does this file do?

This is the English counterpart to fr.lua. Every key is identical. Only the values change. When the player switches to English, i18n.T() returns from this table instead of the French one.

  • Save locales/en.lua.
  • Verify all three files exist by running:
ls i18n.lua locales/

You should see i18n.lua listed, plus en.lua and fr.lua inside locales/.

Files missing or misnamed?

  • Double-check that fr.lua and en.lua are inside the locales/ folder, not the project root. The i18n.loadLocale() function uses require("locales.fr") which expects that exact path.
  • Make sure the filenames are lowercase. Lua's require is case-sensitive on Linux.
  • My locale files aren't loading correctly in LOVE2D

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full i18n.lua:

local i18n = {}

local current_locale = "fr"
local translations = {}

function i18n.loadLocale(locale)
    local ok, tbl = pcall(require, "locales." .. locale)
    if ok and type(tbl) == "table" then
        translations[locale] = tbl
    else
        print("[i18n] Could not load locale: " .. locale)
        translations[locale] = {}
    end
end

function i18n.setLocale(locale)
    current_locale = locale
    if not translations[locale] then
        i18n.loadLocale(locale)
    end
end

function i18n.getLocale()
    return current_locale
end

function i18n.T(key, fallback)
    local tbl = translations[current_locale]
    if tbl and tbl[key] then
        return tbl[key]
    end
    return fallback or key
end

-- Shortcut: callable as i18n("key")
setmetatable(i18n, {
    __call = function(_, key, fallback)
        return i18n.T(key, fallback)
    end
})

i18n.loadLocale("fr")
i18n.loadLocale("en")

return i18n

Here is the full locales/fr.lua:

return {
    -- Splash
    splash_title = "DINO HUNT",
    splash_skip = "Appuie sur Espace pour passer",

    -- Menu
    menu_title = "DINO HUNT",
    menu_play = "Jouer",
    menu_settings = "Paramètres",
    menu_quit = "Quitter",
    menu_nav = "HAUT/BAS : Naviguer ENTRÉE : Sélectionner Cliquer",

    -- Settings
    settings_title = "PARAMÈTRES",
    settings_volume = "Volume musique",
    settings_sfx = "Volume effets",
    settings_fullscreen_label = "Plein écran",
    settings_language_label = "Langue",
    settings_controls = "Contrôles",
    settings_back = "Retour",
    settings_on = "ON",
    settings_off = "OFF",
    settings_nav = "GAUCHE/DROITE : Ajuster ENTRÉE : Confirmer ÉCHAP : Retour",
    lang_fr = "Français",
    lang_en = "English",

    -- Setup
    setup_title = "NOUVELLE PARTIE",
    setup_difficulty = "DIFFICULTÉ",
    setup_clan = "CLAN",
    setup_clan_sun = "SOLEIL",
    setup_clan_moon = "LUNE",
    setup_launch = "LANCER LA PARTIE",
    setup_cards = "cartes",
    setup_nav = "HAUT/BAS : naviguer GAUCHE/DROITE : choisir ENTRÉE : confirmer ÉCHAP : retour",

    -- Gameplay
    turn_label = "Tour",
    score_label = "Score",
    strength_label = "Force",
    end_turn_btn = "Fin de Tour",
    atk_label = "ATK",
    cave_empty = "Vide",
    cave_one = "1 carte",
    cave_label = "Grotte",
    totem_label = "Totems",
    egg_label = "Oeufs",
    enemies_label = "Ennemis",
    remaining = "restant",
    remaining_plural = "restants",

    -- Actions
    activate_title = "Activer",
    action_power = "Pouvoir",
    action_support = "Activer Action",
    action_hunt_left = "Chasser Gauche",
    action_hunt_right = "Chasser Droite",
    action_attack_dino = "Attaquer Dino",
    action_form_ami = "Former Ami",
    choose_action_card = "Choisir une carte Action:",
    choose_destroy = "Choisir une carte à détruire :",
    pair_with = "Paire avec",
    support_cancelled = "Support annulé.",
    pair_cancelled = "Paire annulée.",

    -- Messages
    msg_clan_only = "Seules les cartes Clan peuvent être activées.",
    msg_hunt_result = "%d carte(s) chassées à %s, +%d nourriture",
    msg_action_activated = "Action activée: %s",
    msg_action_pending = "Action en cours: %s",
    msg_cave_swap = "Grotte: échange %s <-> %s",
    msg_cave_stored = "Grotte: %s stockée.",
    msg_cave_select = "Sélectionnez d'abord une carte en main.",
    msg_totem_activated = "Totem activé.",
    msg_not_for_sale = "%s n'est pas à vendre",
    msg_bought = "%s acheté ! Ajouté à la défausse.",
    msg_need_resource = "Il faut %d %s pour acheter %s",
    msg_defeated = "%s vaincu !",
    msg_need_atk = "%s : il faut %d ATK. Vous avez %d",
    msg_turn = "Tour %d",
    msg_new_turn = "Tour %d — Cliquez une carte Clan pour agir",

    -- Game over
    victory_header = "VICTOIRE !",
    defeat_header = "DÉFAITE !",
    final_score = "Score final : %d pts en %d tours",
    replay_prompt = "R — Rejouer | ÉCHAP — Menu",
    score_families = "Familles : %d x 2VP = %d VP",
    score_totems = "Totems : %d x 1VP = %d VP",
    score_dinos = "Dinosaures : %d x 1VP = %d VP",
    score_eggs = "Oeufs : %d VP",
    score_total = "TOTAL : %d VP",

    -- RAGE
    rage_text = "RAGE !",

    -- Name entry
    name_congrats = "FÉLICITATIONS !",
    name_score = "Score : %d Tours : %d",
    name_prompt = "Entrez vos initiales :",
    name_nav = "GAUCHE/DROITE : case HAUT/BAS : lettre Taper A-Z ENTRÉE : confirmer",

    -- High scores
    hs_title = "MEILLEURS SCORES",
    hs_rank = "#",
    hs_name = "NOM",
    hs_score = "SCORE",
    hs_empty = "Aucun score enregistré",
    hs_continue = "Appuie sur une touche ou clique pour continuer",

    -- Controls
    ctrl_title = "CONTRÔLES",
    ctrl_action = "ACTION",
    ctrl_key = "TOUCHE",
    ctrl_reset = "Réinitialiser",
    ctrl_back = "Retour",
    ctrl_waiting = "Appuie sur une touche... ÉCHAP = annuler",
    ctrl_nav = "HAUT/BAS : Naviguer ENTRÉE : Assigner ÉCHAP : Retour Cliquer pour assigner",
    ctrl_up = "Haut",
    ctrl_down = "Bas",
    ctrl_left = "Gauche",
    ctrl_right = "Droite",
    ctrl_jump = "Saut",
    ctrl_attack = "Attaque",
}

Here is the full locales/en.lua:

return {
    -- Splash
    splash_title = "DINO HUNT",
    splash_skip = "Press Space to skip",

    -- Menu
    menu_title = "DINO HUNT",
    menu_play = "Play",
    menu_settings = "Settings",
    menu_quit = "Quit",
    menu_nav = "UP/DOWN: Navigate ENTER: Select Click",

    -- Settings
    settings_title = "SETTINGS",
    settings_volume = "Music volume",
    settings_sfx = "SFX volume",
    settings_fullscreen_label = "Fullscreen",
    settings_language_label = "Language",
    settings_controls = "Controls",
    settings_back = "Back",
    settings_on = "ON",
    settings_off = "OFF",
    settings_nav = "LEFT/RIGHT: Adjust ENTER: Confirm ESC: Back",
    lang_fr = "Français",
    lang_en = "English",

    -- Setup
    setup_title = "NEW GAME",
    setup_difficulty = "DIFFICULTY",
    setup_clan = "CLAN",
    setup_clan_sun = "SUN",
    setup_clan_moon = "MOON",
    setup_launch = "START GAME",
    setup_cards = "cards",
    setup_nav = "UP/DOWN: navigate LEFT/RIGHT: choose ENTER: confirm ESC: back",

    -- Gameplay
    turn_label = "Turn",
    score_label = "Score",
    strength_label = "Strength",
    end_turn_btn = "End Turn",
    atk_label = "ATK",
    cave_empty = "Empty",
    cave_one = "1 card",
    cave_label = "Cave",
    totem_label = "Totems",
    egg_label = "Eggs",
    enemies_label = "Enemies",
    remaining = "remaining",
    remaining_plural = "remaining",

    -- Actions
    activate_title = "Activate",
    action_power = "Power",
    action_support = "Support Action",
    action_hunt_left = "Hunt Left",
    action_hunt_right = "Hunt Right",
    action_attack_dino = "Attack Dino",
    action_form_ami = "Form Ally",
    choose_action_card = "Choose an Action card:",
    choose_destroy = "Choose a card to destroy:",
    pair_with = "Pair with",
    support_cancelled = "Support cancelled.",
    pair_cancelled = "Pair cancelled.",

    -- Messages
    msg_clan_only = "Only Clan cards can be activated.",
    msg_hunt_result = "%d card(s) hunted %s, +%d food",
    msg_action_activated = "Action activated: %s",
    msg_action_pending = "Action in progress: %s",
    msg_cave_swap = "Cave: swapped %s <-> %s",
    msg_cave_stored = "Cave: %s stored.",
    msg_cave_select = "Select a hand card first.",
    msg_totem_activated = "Totem activated.",
    msg_not_for_sale = "%s is not for sale",
    msg_bought = "%s bought! Added to discard.",
    msg_need_resource = "Need %d %s to buy %s",
    msg_defeated = "%s defeated!",
    msg_need_atk = "%s: need %d ATK. You have %d",
    msg_turn = "Turn %d",
    msg_new_turn = "Turn %d — Click a Clan card to act",

    -- Game over
    victory_header = "VICTORY!",
    defeat_header = "DEFEAT!",
    final_score = "Final score: %d pts in %d turns",
    replay_prompt = "R — Replay | ESC — Menu",
    score_families = "Families: %d x 2VP = %d VP",
    score_totems = "Totems: %d x 1VP = %d VP",
    score_dinos = "Dinosaurs: %d x 1VP = %d VP",
    score_eggs = "Eggs: %d VP",
    score_total = "TOTAL: %d VP",

    -- RAGE
    rage_text = "RAGE!",

    -- Name entry
    name_congrats = "CONGRATULATIONS!",
    name_score = "Score: %d Turns: %d",
    name_prompt = "Enter your initials:",
    name_nav = "LEFT/RIGHT: slot UP/DOWN: letter Type A-Z ENTER: confirm",

    -- High scores
    hs_title = "HIGH SCORES",
    hs_rank = "#",
    hs_name = "NAME",
    hs_score = "SCORE",
    hs_empty = "No scores recorded",
    hs_continue = "Press any key or click to continue",

    -- Controls
    ctrl_title = "CONTROLS",
    ctrl_action = "ACTION",
    ctrl_key = "KEY",
    ctrl_reset = "Reset",
    ctrl_back = "Back",
    ctrl_waiting = "Press a key... ESC = cancel",
    ctrl_nav = "UP/DOWN: Navigate ENTER: Assign ESC: Back Click to assign",
    ctrl_up = "Up",
    ctrl_down = "Down",
    ctrl_left = "Left",
    ctrl_right = "Right",
    ctrl_jump = "Jump",
    ctrl_attack = "Attack",
}
Wire i18n into all game screens

The translation tables exist, but the game still draws hard-coded French strings. You need to replace every hard-coded string in main.lua with an i18n.T() call that looks up the matching key.

  • Open main.lua in VS Code.
  • Add the i18n require near the top of the file, alongside your other requires:
local i18n = require("i18n")

Now you will work through each draw function and replace hard-coded strings with lookup calls. The pattern is the same everywhere.

What is the replacement pattern?

For every hard-coded string, find the matching key in your locale files and call i18n.T("key") instead. Here are the two shapes you will use:

  • Static strings: replace "Jouer" with i18n.T("menu_play").
  • Dynamic strings with numbers: replace string.format("Tour %d", state.turn) with string.format(i18n.T("msg_turn"), state.turn).
  • In drawMenu(), find every string like "Jouer", "Paramètres", and "Quitter". Replace each one with its i18n.T() equivalent:
-- Before:
love.graphics.printf("Jouer", ...)
-- After:
love.graphics.printf(i18n.T("menu_play"), ...)
  • Apply the same replacement across all draw functions: drawSplash(), drawSettings(), drawSetup(), drawGame(), drawNameEntry(), and drawHighScores().
  • Replace all status messages and in-game text (turn announcements, buy/sell messages, victory/defeat headers) with their keyed equivalents.
  • For messages that include variables (turn numbers, card names, scores), wrap the lookup in string.format():
-- Turn announcement with a dynamic turn number
state.statusMessage = string.format(i18n.T("msg_new_turn"), state.turn)

-- Purchase message with a dynamic card name
state.statusMessage = string.format(i18n.T("msg_bought"), card.name)

-- Score display with two dynamic values
local txt = string.format(i18n.T("final_score"), state.score, state.turn)

How do I know which key to use?

Open locales/fr.lua side by side with main.lua. Search for the French string you are replacing (like "Fin de Tour") in the locale file. The key on the left side of the = sign (end_turn_btn) is what you pass to i18n.T().

If a French string does not have a key yet, check whether you missed it in the locale files. Every user-facing string should have a corresponding entry.

  • Save main.lua.
  • Run the game with love . and navigate through the menu, settings, setup, and gameplay screens.

Everything should still display in French since the default locale is "fr". The key test right now is that nothing looks broken or shows raw keys instead of text.

Seeing raw keys like "menu_play" instead of text?

  • This means the lookup failed for that key. Check that the key string in your i18n.T() call exactly matches the key in locales/fr.lua. A single typo or case difference will cause a miss.
  • Check the terminal for [i18n] Could not load locale: fr messages. If you see one, the locales/fr.lua file has a syntax error or is in the wrong folder.
  • My i18n.T() calls are returning the key string instead of translated text
Add a language toggle to Settings and persist the choice

The i18n system works, but the player has no way to switch languages from inside the game. You will add a language row to the Settings screen that cycles between French and English and saves the choice to disk.

  • In main.lua, find the drawSettings() function.
  • Locate the row that draws the fullscreen toggle.
  • Add a new language row directly below the fullscreen row and above the controls row. Use this pattern:
-- Language toggle row in drawSettings()
-- Display the label from i18n so it updates when the language switches
love.graphics.printf(i18n.T("settings_language_label"), ...)

-- Show the current language name
local lang_key = "lang_" .. (Settings.language or "fr")
love.graphics.printf(i18n.T(lang_key), ...)

What does this code do?

The label uses i18n.T("settings_language_label") so it displays "Langue" in French and "Language" in English.

The value builds a dynamic key like "lang_fr" or "lang_en" from the current setting. This resolves to "Français" or "English" in either locale, so the language name always appears in its native form.

Next, you need to handle the input that toggles the language when the player selects this row.

  • Find the input handler for the Settings screen (the section that responds to Enter or mouse clicks on settings rows).
  • Add a case for the language row that cycles between "fr" and "en", then immediately applies the change:
-- Toggle language when the language row is selected
if Settings.language == "fr" then
    Settings.language = "en"
else
    Settings.language = "fr"
end
-- Apply the change immediately so all labels update
i18n.setLocale(Settings.language)
-- Persist to disk
Settings.save()

What does this code do?

  • The toggle flips Settings.language between the two locale codes.
  • i18n.setLocale() switches the active translation table. Because every draw function calls i18n.T() each frame, all on-screen text updates instantly.
  • Settings.save() writes the choice to settings.txt so it persists when the player restarts the game.

Finally, the Settings module needs to know about the new language field so it can save and load it.

  • Open scripts/settings.lua.
  • Add a default value for the language field alongside the existing defaults:
-- Add to the Settings defaults
Settings.language = "fr"
  • Find the save function that writes to settings.txt. Add a line that writes the language key following the same key=value format as the existing fields.
  • Find the load function that reads from settings.txt. Add a case that reads the language key back into Settings.language.
  • In love.load() inside main.lua, after settings are loaded, apply the saved language by calling:
-- Apply the persisted language on startup
i18n.setLocale(Settings.language)

Why call setLocale on startup?

The i18n module defaults to "fr" when it first loads. If the player previously chose English and saved that to settings.txt, you need to tell the i18n module about it after settings are loaded. Without this call, the game would always start in French regardless of the saved preference.

  • Save scripts/settings.lua and main.lua.
  • Run the game by running:
love .
  • Open **Settings** from the main menu.
  • Navigate to the new language row and press **Enter** or click it.

You should see the value cycle from "Français" to "English". Every label on the Settings screen should switch to English immediately. Navigate back to the main menu and through the other screens to confirm all text has changed.

  • Close the game and relaunch it with love . to verify the language choice persisted.

The game should start in whichever language you last selected.

Language not persisting after restart?

  • Check that your save function in scripts/settings.lua writes a language=fr or language=en line to settings.txt. You can open the file to inspect it.
  • Make sure your load function reads the language key AND that i18n.setLocale(Settings.language) is called in love.load() after the settings load.
  • My LOVE2D game language setting is not saving between sessions

Your game now speaks two languages. Every screen, every status message, and every label switches instantly when the player toggles the setting. Next up, the Secret Mission challenges you to add smooth card movement animations.

Secret mission

Add Smooth Card Movement Animations

Right now, cards teleport between zones. In this mission, you'll build a tweening system so cards glide smoothly across the board. You'll also add a hover scale effect and a defeat flash to make the game feel responsive and alive.

Before you go

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources or ongoing costs, so decide whether to keep your files as-is, close your tools, or delete the project folder.

Resources you used:

  • The lua_dinoB project folder on your Desktop (all Lua source files, card art, SFX, and music).
  • LOVE2D 11.5 installed on your system.
  • VS Code (your code editor).
  • A Microsoft Designer account (used to generate card art in a browser).

Keep everything running

No action needed. Choose this if you plan to keep building on Dinoblivion or want to play your game anytime.

  • Your lua_dinoB folder stays on disk. Run love . from the project directory anytime to play.
  • Push your changes to GitHub to back up your work and share your progress.

Pause - I'll come back to this later

Close your running tools to free up memory. Everything is just files on disk, so nothing will be lost.

  • Close the LOVE2D game window if it is still open.
  • Close VS Code.
  • Your project files remain on disk. Open the lua_dinoB folder in VS Code and run love . whenever you are ready to pick up again.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone unless you have a backup on GitHub.

  • Delete the lua_dinoB folder from your Desktop.
  • Keep LOVE2D installed if you plan to work on other LOVE2D projects. Otherwise, uninstall it through your system's package manager.
  • Your Microsoft Designer account is browser-based and free. No cleanup needed there.

Mission accomplished

That's a Wrap!

Nice work! You just turned a programmer-art prototype into a polished, bilingual card game with AI-generated artwork, sound effects, and background music.

You've learned how to:

  • Built an image loading and caching system that renders AI-generated card art with graceful fallbacks for missing assets.
  • Generated retro SFX with jsfxr and wired them into every major game action through the existing audio scaffold.
  • Created a lightweight i18n system with French and English translation tables and a persistent language toggle in Settings.
  • Added smooth card movement animations with a tweening system and hover effects.

Ready to quiz yourself?