Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Perentie is a Lua-based graphical adventure game engine. The design is heavily inspired by LucasArts' SCUMM and GrimE adventure game engines.

Perentie is designed for the hardware constraints of Pentium-era MS-DOS. You can run it in any of the following environments:

  • MS-DOS, including as a child process from inside Windows 3.1/95/98
  • Natively on most platforms (via SDL3)
  • Embedded in a webpage (via SDL3 + Emscripten)

Featuring:

  • Lua-based scripting API
  • Co-operative threading
  • 320x200 resolution 256 colour VGA graphics
  • Programmable dithering engine (convert your graphics to EGA and CGA!)
  • Bitmap text rendering with support for UTF-8
  • PC speaker tone/sample playback
  • OPL2/OPL3 music playback
  • Debug shell over null modem/Telnet connection

Perentie was originally created for DOS Games Jam July 2024.

Getting Started

This guide is intended to provide a basic introduction to engine concepts. For information about Perentie's Lua scripting API, see the API docs.

The best way to get introduced to Perentie's internals is to download the source code of the demonstration game, Maura & Ash. This game makes use of the full smörgåsbord of engine features. Try modifying the game code, and then press CTRL+R in the game to hot-reload it.

There is a fully commented basic example program included with the Perentie source code. Try modifying the game code, and then press R in the game to hot-reload it.

Design philosophy

  • The engine will be designed around the hardware constraints of a Pentium PC from 1996.
  • The hardware interface and main loop will be written in C.
  • The scripting API will be written in Lua.
  • As much of the engine as possible should be written in Lua rather than C.
  • Engine state should be stored in one place, tightly defined and serialisable.
  • The engine should run at feature parity on three platforms: MS-DOS, SDL3 + POSIX, and WebAssembly.

Development environment

Perentie's biggest departure from existing adventure game development systems such as Adventure Game Studio is that there is no Integrated Development Environment. Games are written as Lua code, and assets are stored as plain files. There are no special Perentie file formats.

There are two main advantages of this approach: plain files are much easier to version control, and you can use your favourite tools to handle development instead of wrestling with a custom editor.

Right now the biggest drawback is that graphical layout operations (e.g. drawing items in a room) require writing code; later on we will talk about the easiest way to import graphics and room layouts from your image editing tool into Lua.

Perentie does keep one of the strengths normally associated with custom IDEs; you can hot-reload the engine and instantly test changes to the code and assets. The game state is designed to be minimal and serialisable, making it easy to save and load in a way that doesn't break with future revisions of the game code.

Project tips

If you use a code editor with language server support (e.g. Visual Studio Code, Neovim + coc.nvim), we recommend installing the Lua language server to get code completion support. There's various ways of making the language server aware of the Perentie API (entirely defined in src/boot.lua); the easiest one is checking out a copy of the Perentie source code and moving your project into a subdirectory.

Compiling

In order to build Perentie, you will need the following tools in your PATH:

MS-DOS executable

You will need a copy of the DJGPP cross-compiler toolchain. We recommend building one using Andrew Wu's DJGPP build scripts. Sadly the original DOS-native DJGPP is not supported; it's utterly glacial, and not compatible with the build system.

source /path/to/djgpp/setenv
meson setup --cross-file=i586-pc-msdosdjgpp.ini build_dos
cd build_dos
ninja 

This will produce the single DOS executable perentie.exe, which can be renamed and shipped with your game.

SDL executable

You will also need:

  • A POSIX-compatible C compiler toolchain, such as GCC or Clang
  • SDL3
meson setup build_sdl
cd build_sdl
ninja

This will produce the single executable perentie, which can be renamed and shipped with your game.

For an improved debugging experience, you will want to turn off optimisation and turn on AddressSanitiser.

meson setup -Doptimization=0 -Db_sanitize=address build_sdl

WebAssembly

You will also need:

meson setup --cross-file=wasm32-emscripten.ini build_wasm
cd build_wasm
ninja

You will need to package your game contents into a prefetch module in order for Perentie to be able to start.

/usr/lib/emscripten/tools/file_packager game.data --js-output=game.js --preload ../example

To test the WebAssembly version locally, the following command will start a Python webserver with the correct COOP/COEP headers set:

ninja webserver

Documentation

You will also need:

  • LDoc - for Lua API docs
  • mdBook - for the Perentie User Guide
meson setup build_sdl
cd build_sdl
ninja doc
ninja guide

Graphics

Perentie is designed for the limits of the MS-DOS "Mode 13h" VGA graphics mode: a resolution of 320x200 with 256 colours.

Images

Perentie supports exactly one image format: PNG. Specifically, PNG using the 8-bit indexed or grayscale format.

The exact palette layout of each source image doesn't matter, however images should use a consistent set of colours so as not to run out of palette slots.

You can convert a normal PNG to 8-bit with ImageMagick:

magick convert source.png -colors 256 PNG8:target.png 

The palette

The first 16 palette slots are always mapped to the standard 16 EGA/CGA colours.

The remainder of the palette is defined by loading in images; Perentie will keep a running tab of all the colours used and convert your graphics for the target hardware. Once 256 colours have been used, subsequent colours will be remapped to the nearest matching colour.

Fonts

Perentie supports loading bitmap fonts in AngelCode BMFont format.

To get you started, Perentie includes BMFont copies of the following typefaces, generated from TTFs produced by the Oldschool PC Font Resource:

For making your own fonts, the BMFont tool allows you to convert TrueType/OpenType files into a descriptor file + atlas textures, using any selection of Unicode characters. We recommend setting it up with the following export options:

Padding: 0
Spacing: 1
Width: 256
Height: 256
Bit depth: 8
Channel: encoded glyph & outline
Font descriptor: Binary
Textures: png - Portable Network Graphics
Compression: Deflate

If you are storing your files loose, be sure to pick a filename with 6 letters or less so that the end result is DOS compatible.

Getting the correct font settings can be tricky. For vector fonts showing a pixel typeface, you will want to disable all of the smoothing options and set the size to be the one that matches the font's pixel grid, usually 8px or 16px. Try exporting a few times and check the PNG to see if the pixel output is correct.

Sound

Perentie is designed for the limits of MS-DOS audio hardware: music using an OPL2/OPL3 chip, and sound effects via the PC speaker.

OPL2/OPL3 Music

Perentie supports playing music using a Yamaha FM chip; namely the YM3812 OPL2 (AdLib, early SoundBlaster) and the YMF262 OPL3 (SoundBlaster 16).

Under DOS this is done by accessing the hardware through the standard port (388h), whereas modern builds use a bundled copy of the DOSBox project's WoodyOPL OPL3 emulator.

As of now, the only supported music playback format is Reality Adlib Tracker v2, but we hope to add support for playing standard MIDI files soon.

State

State can be described as the set of facts required to recreate the engine at a particular point in time. Perentie tries to adhere to the model-view-controller pattern; there is a set of data (model) which can be changed by the user clicking and triggering code in event callbacks (controller) and displayed as stuff happening on the screen (view). Any information kept outside of the model is considered ephemeral, and must be set up on loading.

Right now, the model data in a Perentie state consists of the following:

  • Everything in the variable store
  • For all PTActors:
    • Position
    • Facing
    • Room location
  • For all PTRooms:
    • Camera position
    • Camera actor
  • Current room

The variable store

The variable store is accessible at any time by calling PTVars. We recommend using it to store the bare amount of information required to represent the player's progress.

The variable store can only be used for storing primitive types; booleans, numbers, strings, and tables.

You cannot store PT objects in the variable store; they will clash with objects created when the engine has started, and some are just pointers to data in memory (e.g. PTImage) which can't be serialised. Perentie will raise this in the log whenever it notices this happening.

Saving the state

You can save the current state using PTSaveState(slot), where slot is a number from 0-999. Saved states will be stored in your application's local app data directory with the filename SAVE.000, SAVE.001... all the way to SAVE.999. We recommend using slot 0 for autosave/hot reload. Information about which state slots are in use can be fetched using PTGetSaveStateSummary.

Loading the state

When loading a state, Perentie does the following:

  • Send a "reset" event. This includes the path to the state file to load.
  • Wait until the start of the next frame. The event loop has finished processing, the scene graph has been rendered, and the display page has been flipped.
  • Close the main Lua thread. This will destroy and garbage collect all the objects, including threads and references to data outside of Lua.
  • Reset any palette modifications.
  • Create a new main Lua thread.
  • Run through the normal init process. Essentially this imports all of the engine + game Lua code.
  • Unlike a normal init, do not raise a "start" event.
  • Read the data from the state file.
  • If the game has set up an event callback with PTOnLoadState, call this function with the state data. If you need to make fixups to the state data so that older save files work on newer versions of the game, here's the place to do it.
  • Apply the state data to the engine. This will overwrite the contents of the variable store, along with anything mentioned as part of the model.
  • If the game has set up an event callback for the destination room with PTOnRoomLoad, call this function.

Storage

Perentie uses PhysicsFS as the storage backend.

You can provide multiple archives to Perentie for reading files. When opening a file for reading, the engine will query each of these sources in sequence until it finds a match. Writes are always limited to one location; the local app data directory.

Perentie will search for game files in the following order:

  • Loose files in the local app data directory for the game
  • Loose files in the game directory
  • Archives in the game directory (*.pt, checked in alphabetical order)

The name of the local app data directory is based on the id parameter you pass to PTSetGameInfo - e.g. ~/.local/share/au.net.moral.perentie.example.

Archives are files in the ZIP archive format with the file extension changed from .zip to .pt. We recommend creating ZIP archives without compression for extra speed.

We recommend that all game files, whether loose or inside an archive, assume a case-sensitive filesystem and use lowercase text for the filenames. In order to be compatible with DOS, loose files must have 8.3 filenames, however files stored inside an archive can have UTF-8 filenames of any length.

Debugging

Logging

Perentie provides a simple API for log messages:

local errcode = 10
local errormsg = "a mistake"
PTLog("something went wrong: %s, value=%d\n", errormsg, errcode)

PTLog uses the same syntax as Lua's string.format. If you need to print the contents of a table, you can run them through the bundled copy of inspect.lua, which does a good job converting these to human-readable strings. Be aware that this conversion can be expensive in terms of cycles.

You can enable logging by passing the --log command line option. For DOS builds, PTLog will write logs to the file perentie.log. For SDL builds, PTLog will print logs to the console.

Lua shell

Perentie includes a built-in Lua shell, accessible over a COM port via a null-modem connection. For DOSBox Staging users, all that's required is to add the following line to your dosbox.conf:

[serial]
serial4       = nullmodem telnet:1 port:42424

In your game's Lua code, add the following call:

PTSetDebugConsole(true, "COM4")

We do not recommend enabling this in your release builds. Real DOS machines tend to use COM ports for communicating with hardware, and reading an endless barrage of nonsense from the COM lines will tank performance.

When the engine is running, you can connect to the shell on port 42424 using a Telnet client:

$ telnet localhost 42424
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

┈┅━┥ Perentie v0.9.0 - Console ┝━┅┈
Lua 5.4.7  Copyright (C) 1994-2024 Lua.org, PUC-Rio

>> PTVersion()
"0.9.0"
>> 

This provides a very basic REPL interface from inside the main thread of the engine, similar to the one you get from running the lua CLI. Results from any commands you run will be printed to the shell; these are filtered through inspect.lua so that e.g. tables will display by default. In order to print to the debug shell from your game code, you can use the print function.