- Author: Douglas P. Fields, Jr.
- License: MIT (see LICENSE), per original author
- Changes Copyright: Douglas P. Fields, Jr.
- Original Copyright: SANO, Masatoshi
- Original README
Note: This project targets DotCL 0.1.17.
This is an implementation of the MonoGame 2D Dungeon Slime demo in as Lispy a fashion as possible. It is a learning vehicle for how to build MonoGame games in DotCL Common Lisp. Learnings will also be passed on to the DotCL project as feature suggestions and bug reports.
The goals of this project are:
-
Move as much of the functionality into basic Common Lisp and CLOS as possible.
-
Make a clean set of Lispy functions for interacting with MonoGame.
-
Build various utilities and frameworks to make interacting with C#, CLR and MonoGame (and other non-Common Lisp platform things) ergonomic, reliable and well-documented.
This is heavily a work in progress. The code may not be as clean as I would like, but this is primarily a proof of concept for creating another game in SANO-san's awesome DotCL Common Lisp later.
This package uses my C# lisp package generator
(dotcl-packagegen), which originally was a part of this code, but is now standalone.
See Use and Integration of C# Assemblies as Lisp Packages below for more details.
Please clone the package generator and run make build test deploy to get the
dotcl-packagegen command line tool installed. This is needed by the
build system if you wish to run make cspackages. If you do not edit
the cspackages, you do not need to install the tool.
I've built and tested this on Linux on x64 only. I make no guarantees that this will work on any other platform.
You will need to check out dotcl
in a sibling directory to this one
and build and install the dotcl tool. This demo uses various files
assuming they are in that sibling directory. These references are
in the DungeonSlime.csproj file.
The game is tested and works out-of-the-box on DotCL 0.1.17.
This updated version includes massive performance boosts (via typed dotnet:invoke natively compiling to callvirt, and unboxed float/int64 numerics), async/await, and a drastically improved interop model (like automatically inferring receiver types, extension methods, and resolving generics).
A custom-patched DotCL is no longer necessary.
Preparation:
- To use the MonoGame Content Builder, in this project's home directory:
dotnet tool restore- This installs
dotnet-mgcbanddotnet-mgcb-editoranddotnet-mgcb-editor-linuxamong other things.
- This installs
As of DotCL 0.1.15+ (currently targeting 0.1.17), the project build system is migrated to a pure NuGet package reference structure
(<PackageReference Include="DotCL.Runtime" Version="0.1.17" />). This removes the need for a local
sibling dotcl repository check-out.
Key features of the updated build system:
- Quicklisp Integration via Build-Init: The project utilizes DotCL's
<DotclBuildInit>task pointing to build-setup.lisp. At build time, this script loads the local Quicklisp installation (~/quicklisp/setup.lisp) to register ASDF system search hooks. External systems (such asanaphora) are resolved, compiled, and bundled into the output directory automatically without requiring manualCL_SOURCE_REGISTRYenvironment variables or wrappers.- Note: a DotCL Patched QuickLisp is available.
I recommend installing QuickLisp normally with SBCL (or whatever existing CL implementation
you have that is supported by QL) then edit the installed
~/quicklisp/files per the patch above, and then just use it normally with DotCL.
- Note: a DotCL Patched QuickLisp is available.
I recommend installing QuickLisp normally with SBCL (or whatever existing CL implementation
you have that is supported by QL) then edit the installed
- Automated Reference Copying: A custom MSBuild target (
CopyReferencesBeforeLisp) copies all referenced NuGet assemblies (likeMonoGame.Framework.dll) to the output folder before Lisp compilation runs, in an attempt to prevent compile-time assembly load errors. (They still happen, though.) - Decoupled Compile-time Assemblies: The Lisp compilation process does not require loading
DungeonSlime.dllduring the:compile-toplevelphase (avoiding circular dependency or file missing errors on clean builds). The assembly is dynamically loaded only during runtime:load-topleveland:executephases, so that custom C# types (such asMonoUtilsRegistrar) are successfully resolved during standalone interactive REPL sessions. - Self-Contained Executable Bundle: Compiled dependency FASLs (e.g.
anaphora.fasl,dotcl-repl.fasl) and the load manifest (dotcl-deps.txt) are placed next to the executable in thedotcl-fasl/directory. Additionally, the standardcontrib/Lisp library is copied directly from the restored NuGet package cache folder ($(_DotCLContribDir)) to the build output directory, allowing the entirebin/directory to be copied and run standalone on another machine.
You can use the provided Makefile to build, test, and run the project:
- Rebuild C# Packages:
make cspackages - Build the project:
make build(runs the consolidateddotnet buildcommand in a single step; also gates onmake check-parensandmake content-fontsfirst, see below) - Build then run the test suite:
make check(the one command to run before declaring a change done) - Run the test suite:
make test(runs the game in--testmode; does not build first, usemake build testormake check) - Run the game:
make run(runs the GUI game; does not build first, usemake build run) - Clean build files:
make clean(cleans temporary directories and compiled FASL files) - Deep-clean build files:
make deep-clean(make cleanplus the second, independent ASDF FASL cache under~/.cache/common-lisp/thatmake cleandoes not touch; seedoc/implementation-notes.md) - Run MonoGame Content Builder:
make mgcb - Check Lisp parentheses balance:
make check-parens(runs automatically as part ofmake build)
NOTE: Font Rebuilds: The MonoGame Content Builder (MGCB) task only monitors changes to the
.spritefontXML descriptor, not the raw.ttffont file underContent/fonts/that it references, so an edited/replaced.ttfwould otherwise silently fail to rebuild.make buildrunsmake content-fontsfirst, which touches any.spritefontwhose referenced.ttfis newer, so this no longer needs to be done by hand.
Or manually run the steps:
-
To build:
dotnet build DungeonSlime.csproj -v d -c Debug-v nmakes Lisp compilation errors be shown.-v dshows more details.-v diagshows the most, but I haven't really noticed a difference.
-
To run:
bin/Debug/net10.0/ubuntu.24.04-x64/DungeonSlime(or whatever your platform-specific path is)- To run in test mode, add
--testto the command line, and it will not invoke the game.
- To run in test mode, add
-
To generate C# assembly lisp packages:
make cspackages -
If you want to edit the
.mgcbfile, execute:dotnet tool run mgcb-editor-linux -
To check Lisp parentheses balance manually:
find . -type f \( -name "*.lisp" -o -name "*.asd" \) ! -path "*/obj/*" ! -path "*/bin/*" ! -path "*/.git/*" ! -path "*/scratch/*" | xargs python3 check_parens.py(this is whatmake check-parensruns; thescratch/exclusion matters because that gitignored directory holds throwaway files that are sometimes intentionally unbalanced)- Parentheses balance is usually not much of a problem for human coders due to IDE support, but it seems to trip up AI assistance a lot. The problem is compounded due to DotCL's mechanism of concatenating all Lisp files into a single input during compilation phase.
First, build the game per the above. This ensures the C# files are compiled and copied into the output directory, and the ContentManager assets are generated.
Invoke DotCL REPL using the provided Makefile target:
make repl
(Which executes dotcl --eval '(load "load-repl.lisp")' --eval '(in-package :dungeon-slime)' repl
directly).
Alternatively, invoke DotCL manually with rlwrap --always-readline dotcl (or omit rlwrap if you
prefer), then:
;; Load all the necessary dependencies and make a game instance as
;; dungeon-slime::*mg-game*
(load "load-repl.lisp")
(in-package :dungeon-slime)
;; Execute the game then, with this:
(dotnet:invoke *mg-game* "Run")
;; After the game executes and returns to the REPL, clean up with this:
(dotnet:invoke *mg-game* "Dispose")
;; otherwise the game window will just sit there and cause grief.
;; To run the tests from the REPL:
(dungeon-slime-tests::run-all-tests)It is not possible to run the game a second time after it exits under the current MonoGame system. It seems others have tried; see this issue. In my case, it shows the game window and then segfaults out.
This test ensures that there are no unmet dependencies when the application is built and run by another user (as if on another computer).
Create another user, I use one called dummy.
cp -a bin /tmpto copy the built application there- Enable the user to write to your X session:
xhost +si:localuser:dummy su - dummyexport DISPLAY=:0/tmp/bin/Debug/net10.0/arch-x64/DungeonSlimeto run the game; ensure it works
To ensure the application is fully portable and runs successfully when executed by another user account (or from a different directory), several design and build system choices are implemented.
- Raw Sound File Copying: Raw
.wavaudio files are explicitly copied to the output folder via the MSBuild project file (DungeonSlime.csproj) so they are available on disk for native filesystem loading. - C# Interop Path Qualification: Relative paths passed to filesystem-based
constructors (like
SoundEffect.FromFileandSong.FromUri) are resolved relative to the executable's directory usingqualify-path. The paths are coerced to strings usinguiop:native-namestringto prevent interop method signature mismatch crashes. - Flexible URI Parsing: Theme song URIs are constructed using
system-uri-kind:+relative-or-absolute+to ensure they parse Unix absolute paths correctly without throwing formatting exceptions. - Graceful Subsystem Fallback: Audio hardware initialization and loading is
protected by
handler-caseblocks. If the target environment blocks sound server connections (e.g. PipeWire socket permissions), the game runs in silent mode rather than crashing.
- Qualify Filesystem Paths: Never pass raw relative strings directly to
foreign filesystem methods. Wrap them in
qualify-pathto ensure directory independence. - Ensure String Conversion: Always call
uiop:native-namestringon resolved pathnames before passing them to C# constructors to avoid type dispatch failures. - Declare Custom Assets in Project File: Any raw asset loaded directly via the
filesystem (bypassing the MonoGame
ContentManagerpipeline) must be declared as<Content>with<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>inDungeonSlime.csproj. - Trap Host Subsystem Exceptions: Wrap host hardware and driver initialization
routines (sound, graphics, network) in
handler-caseblocks so the game degrades gracefully on restricted host environments.
Much of the code herein is written by myself. I enjoy the act of writing code (and especially documentation) and knowing how everything is put together.
However, I do use ML coding tools, primarily Antigravity CLI/Gemini, but I have also been dabbling in OpenCode with various local models and even tried Claude Code CLI.
I have found the tools to be extremely useful are in several cases:
-
Explanations: Gemini 3.5 Flash especially has been extremely good at explaining what is going on in SANO-san's DotCL and how it interacts with C# / CLR. I have found its explanations to be so clear that I have been saving some of them in various
.mdfiles herein. -
Troubleshooting: When I hit a wall and can't figure something out, Gemini often is able to get me going again. And, I mean, much faster than my traditional Googling it and reading docs and staring at the screen. Embarrassingly, although I've been Lisping for almost 40 years now, I still sometimes make dumb errors like having a paren in the wrong place, which it finds very quickly. (I don't use paredit or other structural editing tools. I am happy this way. I do love "rainbow parentheses" though!)
-
Planning: Fleshing out my plans into more detailed plans, or finding problems or gaps in my plans.
-
Coding: When I have something well defined that I just don't feel like writing (usually because I've written it a jillion times before), I will ask Gemini to do it. I usually overspecify my instructions compared to what I have seen other tool users do, but it also means that in general the output gets pretty close to my standards and I can use it with only some edits.
MonoGame Dungeon Slime features:
- All Chapters 1-9 content
- Classes are implemented as CLOS classes
- The base CLOS Core class does callbacks into a MonoGame proxy Game class.
- We don't use an XML file to define the Texture Atlas; we use a Lisp property list.
- Classes are implemented as CLOS classes
- All Chapter 11 content (input management)
KeyboardInfo,MouseInfo,GamePadInfo, andInputManagerCLOS classes- Frame-to-frame state tracking for "just pressed" / "just released" detection
- Timed vibration management for gamepads
- All Chapter 12 content (collision detection)
circleCLOS class with boundary functions and intersection testing- Screen boundary blocking for the player slime
- Screen boundary bouncing for the enemy bat via
Vector2.Reflect - Slime-vs-bat trigger response with random respawn
- Rectangle intersection and containment utilities
- All Chapter 13 content (tilemaps)
- Tilemap loaded via S-expression lists instead of XML
- Tilemap and Tileset CLOS classes
- Room boundaries scaled according to tilemap
- All Chapter 14 content (audio)
- Looped background music playback using
media-playerstatic methods - Bounce sound effects triggered on screen boundary collisions
- Collect sound effects triggered on slime-vs-bat collisions
- Automated loading verification test suite in
audio-test.lisp
- Looped background music playback using
- All Chapter 15 content (audio controller)
audio-controllerCLOS class for centralized audio lifecycle management- Tracks and disposes of
SoundEffectInstanceobjects automatically - Global mute state (
Mkey) and volume controls (+/-keys)
- All Chapter 16 content (SpriteFonts)
sprite-font.lispwithload-font,measure-string,draw-stringhelpers- Score tracking system (migrated to
gameplay-scene) - Score increments by 100 when slime collides with bat
- Score text rendered in top-left corner using
04B_30.ttfpixel font - SpriteFont loaded via proper MonoGame content pipeline
- All Chapter 17 content (Scene Management)
- Pure CLOS-based scene lifecycle (
sceneclass) specializinginitialize,load-content,update,draw, anddispose. - Staged transition queue (
active-sceneandnext-sceneslots oncore) to prevent frame-middle transitions. - Custom
title-scenedisplaying a title, centered logo, and pulsing "Press Enter to Play" text. - Custom
gameplay-scenehousing the slime-eating-bat gameplay loop. - Return to title screen on Escape from the gameplay scene; exit only from the title screen.
- Explicit scene disposal and CLR garbage collection invocation.
- Pure CLOS-based scene lifecycle (
- All Chapter 18 content (Texture Sampling)
- Scrolling tiled background pattern on the title screen using
sampler-state:+point-wrap+. - Two separate
sprite-batchrendering blocks in a single frame to apply different sampler states (PointWrapfor tiling background,PointClampfor crisp UI/text rendering). - Scrolling offsets updated using delta time and wrapped seamlessly via Common Lisp's standard
modfunction. - Fully uses the generated
cspackages/wrappers to avoid direct C# ordotnet:interop calls.
- Scrolling tiled background pattern on the title screen using
Basic in-game REPL:
- Launches a super simplistic REPL background thread that uses standard input and output. Control-D will exit the REPL.
- Demonstrate the REPL interacting with live game state with
(setf color-cycle-period 1.0)to speed the color cycling. - Evaluate
(exit)to quit the game. - Submitted change for
dotcl-replwithconsole-read-key-interruptableto use a busy wait loop checkingConsole.KeyAvailableand sleeping for 50ms intervals, trappingThreadInterruptedExceptionso that the REPL thread can be interrupted cleanly. - REPL is started and stopped by the MonoGame
Game.Run()by hooking intoBeginRun()andEndRun() - Note that the background REPL spawned when running from a top level
REPL session does not interact well if the top level REPL is using rlwrap
like
rlwrap --always-readline dotcl.
MonoUtils Lisp Package written in C#:
- Proof of concept "MonoUtils" package written in C#.
- Provide a shim in
monoutils.lisp. - Have the top level
.asdfile include this shim and make themain.lispdepend on it. - Proof of concept "Add3" function exposed to Lisp as:
(monoutils:add3 1 2 3). - Implement a
monoutils:invoke-genericas an analog to the existingdotnet:static-generic. It supports resolving generic type arguments via Lisp type aliases (e.g.,'("TEXTURE2D")instead of the fully qualified'("Microsoft.Xna.Framework.Graphics.Texture2D")) registered indotnet::*type-aliases*.- Used for calling
Load<Texture2D>()dynamically.
- Used for calling
dotnet-p: Tells if the argument is a Lisp-embedded dotnet/C#/CLR object.boxed-dotnet-p: Tells if the argument is a boxed dotnet/C#/CLR object, created withdotnet:box.get-type: Returns the Type object of a DotNet object, or nil if not a DotNet object. But if the arg is a string, tries to get the DotNet type of that name.get-type-full-name: Returns the string of the type name fromget-typeor nil. So if you pass it a string, it should return the same thing.
MonoGame Framework Classes: Texture Regions, Sprites and Texture Atlases:
- Implemented the CLOS classes
texture-region,spriteandtexture-atlasto manage sprite sheet subdivisions. animationandanimated-spritecontain sprite lists (and delays), and then implement them for MonoGame tapping both theDrawandUpdatecallbacks.- Implemented
ta-from-fileintexture-atlas.lispwhich loads atexture-atlasfrom a Lisp form description file (likeContent/test-atlas.lisp), converting symbol/keyword region names to strings for the atlas registry. It is accompanied by a validation test that runs on startup.
Lisp utility functionality:
safe-read-form-from-fileis used to securely load Lisp-based texture atlas descriptions without read-time evaluation.
The font 04B_30.ttf is the correct pixel font from FontSpace (04b03 font), which is used as Content/fonts/04B_30.ttf in this project.
Coreis just theGame1class renamed
After make repl:
;; Find stuff in the metadata - a CL list of plists
(defparameter y (safe-read-form-from-file "cspackages/MonoGameGum.lispy.metadata"))
;; All the classes FQN
(mapcar (lambda (i) (getf i :fully-qualified-name)) y)
;; All the classes names
(mapcar (lambda (i) (getf i :name)) y)
;; A single class
(find "ContainerRuntime" y :test #'string= :key (lambda (plist) (getf plist :name)))The cspackages/ directory is machine-generated (never hand-edited) by the external
dotcl-packagegen tool,
(also designed by this package's author), driven by the --assembly/--class list
in the Makefile's cspackages target. make cspackages regenerates it; the result
is committed, since generation itself needs a already-built project's assemblies
present to reflect over. Each requested C# class gets its own small Lisp package
wrapping its constructors, methods, and properties as ordinary Lisp functions (e.g.
v2:new, rect:contains), plus one shared csharp-generics package unifying
same-named methods across classes into CLOS generic functions dispatching on eac
object's .NET runtime type. Most generated packages call dotnet:resolve-type
(safely deferred via define-symbol-macro, so it's fine as an ordinary ASDF dependency).
However, csharp-generics.lisp specifically resolves classes at Lisp read time via
#.(dotnet:class-for-type ...) specializers, that one file can only be compiled once
the .NET assemblies it wraps are already loaded into the process.
So, dungeon-slime.asd pulls in cspackages/csharp-assembly-packages.asd as a normal
ASDF system via :depends-on (after setting up ASDF's search path), but the
--no-csharp-generic-in-asd excludes csharp-generics.lisp from the generated ASDF system.
dungeon-slime.asd then re-adds that single file as an ordinary file component,
positioned after type-aliases.lisp, the file that loads C# assembliens such as
MonoGame.Framework.dll.
From there, application code just uses the generated packages like any other Lisp
package, typically through a short :local-nickname declared in packages.lisp
(e.g. v2 for microsoft-xna-framework-vector2), calling things like
(v2:new 1.0 2.0) or referencing a generated constant directly, e.g. v2:+zero+.
Most functions are also available via the generics package, e.g.,
csg:+ or csg:dispose. This makes using C# classes & assemblies much more
Lispy, though there are some sharp edges.
See doc/issue-49-continued.md for the full story of
why this split was necessary. Hopefully a future enhancement to DotCL will obviate
this requirement; see DotCL issue 49
for details.