itch.io is community of indie game creators and players

Devlogs

Game Jam Day 4

The Clock Tower
A browser game made in HTML5

Tag: [day-4]

Objective and puzzles

More brainstorming about things to do in the game and puzzles on the way to the end. We are thinking that you want to work your way to the top of the tower, and there are challenges on the way that will require going out and exploring the world.

  • A combination lock. A puzzle that needs a string of numbers is a natural way to incorporate one of the oldest ideas we've had for this jam, which is a radio station that only gets reception high up on a mountain.
  • Musical combination lock. You need to experience soundscapes in order to learn a melody, rhythm, or dance.
  • Communicating with a bird with the help of another bird.
  • Find some food to feed a cat.

LPeg command parser

Long before the game jam had started, we thought about implementing a command parser as a parsing expression grammar with LPeg. Treat command interpretation like parsing a formal computer language, with sub-languages defined for things like compass directions and interactable items, which can then be re-used in the parsing of commands that need those components.

The purpose of the command parser is to take a command string, like "pick up coin", and convert it into a regular canonicalized form for further processing, like [:TAKE :PENNY]. We're using sequential Lua tables that contain strings as the canonicalized form of commands.

Today we rewrote the command parser with LPeg. Earlier, the parser had been a hand-rolled one. It split the input string into whitespace-separated words, then used Fennel pattern matching to recognize various synonyms for commands and objects. Actually it was not too bad, as these things go.

(lambda parse-itemid [s]
  (case s
    "MUSHROOM" :MUSHROOM
    "FUNGUS"   :MUSHROOM
    "MUSH"     :MUSHROOM
    "PENNY" :PENNY
    "COIN"  :PENNY))

(lambda parse [?cmd]
  (let [cmd (string.upper (or ?cmd "QUIT")) ;; Treat EOF as if the player typed QUIT.
        words (icollect [w (string.gmatch cmd "%w+")] w)]
    (case words
      (where (or ["N" nil] ["NORTH" nil])) [:GO :N]
      (where (or ["S" nil] ["SOUTH" nil])) [:GO :S]
      (where (or ["E" nil] ["EAST" nil]))  [:GO :E]
      (where (or ["W" nil] ["WEST" nil]))  [:GO :W]
      (where (or ["U" nil] ["UP" nil]))    [:GO :U]
      (where (or ["D" nil] ["DOWN" nil]))  [:GO :D]

      (where (or ["I" nil] ["INVENTORY" nil] ["TAKE" "INVENTORY" nil])) [:INVENTORY]

      (where (or ["L" nil] ["LOOK" nil])) [:LOOK]

      (where (or ["QUIT" nil]
                 ["VAMOOSE" nil]
                 ["SCRAM" nil])) [:QUIT]

      (where (or ["EXAMINE" obj nil]
                 ["X" obj nil]
                 ["LOOK" "AT" obj nil]))
      (case (parse-itemid obj)
        x [:EXAMINE x]
        _ [])

      ;; ...
    )))

(Even though they are all strings, we're using the convention that double-quoted strings represent things typed by the player, and colon-prefixed strings represent canonicalized output of the parser. And a minor technical point: the reason all the array patterns end with nil is that pattern matching only checks for equality of a prefix of the value being matched. Without the nil, a ["LOOK"] pattern would match both ["LOOK"] and ["LOOK" "AT"].)

The new LPeg-based parser looks like this (omitting some helper parser combinators, like W which matches complete whitespace-delimited words):

;; Transform a pattern to require consuming the full input, with optional
;; leading and trailing space.
(lambda FULL [cap patt] 
  (* (Cc cap) 
     (^ Sp 0) ;; Optional leading space.
     patt
     (^ Sp 0) ;; Optional trailing space.
     (P -1))) ;; End of line.

(local Bearing
  (+ (* (Cc :N) (+ (W "n") (W "north")))
     (* (Cc :E) (+ (W "e") (W "east")))
     (* (Cc :S) (+ (W "s") (W "south")))
     (* (Cc :W) (+ (W "w") (W "west")))
     (* (Cc :U) (+ (W "u") (W "up")))
     (* (Cc :D) (+ (W "d") (W "down")))))

(local Item
  (* (OPT Article)
     (+ (* (Cc :MUSHROOM)
           (OPT (W "mysterious"))
           (+ (W "mushroom")
              (W "fungus")
              (W "mush")))

        (* (Cc :PENNY)
           (OPT (W "lucky"))
           (+ (W "penny")
              (W "coin"))))))

(local Command
  (+ (FULL :GO
           (* (OPT (* (+ (W "go")
                         (W "walk"))
                      (OPT (W "to" "the"))))
              Bearing))

     (FULL :INVENTORY
           (+ (W "inventory")
              (W "i")
              (W "take" "inventory")))

     (FULL :LOOK
           (+ (W "look")
              (W "l")))

     (FULL :EXAMINE
           (* (+ (W "examine")
                 (W "x")
                 (W "look" "at"))
              Item))

     ;; ...
  ))

(lambda parse [line]
  ;; Return all captures in a sequential table.
  (lpeg.match (lpeg.Ct Command) (string.lower line)))

The parser has a nice regular structure, and it makes it easier to add variations to commands. Here are some commands that work now that didn't work before:

  • walk n
  • go to the north
  • look at the mysterious mushroom
  • pick up the coin
  • drop lucky penny

The LPeg parser feature was our first use of a branch in Fossil.

Caveat with unary operators with Fennel and LPeg

There's a surprise with the * and + operators when used with LPeg patterns in Fennel.

The * operator indicates concatenation: all patterns given as arguments must match in order. For example, this is one way to make a pattern that matches the string "ABC":

(* (lpeg.P "A")
   (lpeg.P "B")
   (lpeg.P "C"))

This compiles straightforwardly to the Lua code:

(lpeg.P("A") * lpeg.P("B") * lpeg.P("C"))

Similarly, with two sub-patterns instead of three, it works like you expect.

(* (lpeg.P "A")
   (lpeg.P "B"))

Compiles to:

(lpeg.P("A") * lpeg.P("B"))

The problem arises when there's just one pattern in the concatenation:

(* (lpeg.P "A"))

You would think this would have the same meaning as just (lpeg.P "A"). But actually it compiles to this:

(1 * lpeg.P("A"))

Starting an empty product with 1 makes sense in terms of arithmetic, but 1, as an LPeg pattern, means to match 1 of any character. The Fennel expression (* (lpeg.P "A")) does not result in a pattern that matches strings beginning with "A", but one that matches all strings that are at least 2 characters long, and whose second character is "A".

There's an analogous situation with +, the ordered choice operator:

(+ (lpeg.P "A"))

When there's just one option, the compiled Lua adds a 0 to the sum, resulting in a pattern that matches all inputs:

(0 + lpeg.P("A"))

To be safe, you can start your * with an lpeg.P(true) (the identity element for *) and your + with an lpeg.P(false) (the identity element for +), so that there's always at least two arguments to the call.

LuLPeg

To avoid the issue of having to compile LPeg for distribution, we replaced LPeg with LuLPeg, a port of LPeg to pure Lua. All our parser test cases passed with LuLPeg, needing no other changes.

Unit tests

With the new parser, we added a file of unit tests. The tests are run with make test via a makefile:

# Is it possible to use the same LuaJIT that love uses?
LUA = lua
.PHONY: test
test:
        $(LUA) test-parser.fnl

The test program is a Fennel program, but run in a Lua interpreter using the polyglot trick.

Like the comment in the makefile says, this uses the system Lua interpreter. The system Lua interpreter is different from the LuaJIT interpreter that comes with Love2d. Is there a way to run Love's LuaJIT for purposes such as this? How do people normally write unit tests for Love programs?

Fossil web UI HTML titles

The default Fossil web UI constructs HTML titles that put the project name first (in our case "Autumn Lisp Game Jam 2025"):

  • Autumn Lisp Game Jam 2025: Timeline
  • Autumn Lisp Game Jam 2025: Dev Log
  • Autumn Lisp Game Jam 2025: Chat
  • Autumn Lisp Game Jam 2025: View Ticket

The problem is, in the browser's tab bar, all of these titles get truncated and become indistinguishable:

  • Autumn Lisp Game Jam 202…
  • Autumn Lisp Game Jam 202…
  • Autumn Lisp Game Jam 202…
  • Autumn Lisp Game Jam 202…

We hacked up a custom skin that's the same as the Default skin, but with the project name shifted to the end of the title. Now the tabs are more usable:

  • Timeline – Autumn Lisp G…
  • Dev Log – Autumn Lisp Ga…
  • Chat – Autumn Lisp Game …
  • View Ticket – Autumn Lisp …

There's a bit of a trick to modifying the HTML title in the skin By default, the "header" part of a skin does not contain the HTML header (where the title is declared); instead, the HTML header comes from a default in the Fossil code. You need to make sure the string <body appears in the template: this tells Fossil that the template should include everything, including the HTML header.

We edited the Header template as instructed, copying the default HTML header before the existing Header template, and changing <title>$<project_name>: $<title></title> to <title>$<title> – $<project_name></title>:

Header template
<html>
<head>
<meta charset="UTF-8">
<base href="$baseurl/$current_page" />
<meta http-equiv="Content-Security-Policy" content="$default_csp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$<title> – $<project_name></title>
<link rel="alternate" type="application/rss+xml" title="RSS Feed" \
 href="$home/timeline.rss" />
<link rel="stylesheet" href="$stylesheet_url" type="text/css" />
</head>
<body class="$current_feature rpage-$requested_page cpage-$canonical_page">
<header>
  <div class="logo">
    <th1>
    ## See skins/original/header.txt for commentary; not repeated here.
    proc getLogoUrl { baseurl } {
      set idx(first) [string first // $baseurl]
      if {$idx(first) != -1} {
        set idx(first+1) [expr {$idx(first) + 2}]
        set idx(nextRange) [string range $baseurl $idx(first+1) end]
        set idx(next) [string first / $idx(nextRange)]
        if {$idx(next) != -1} {
          set idx(next) [expr {$idx(next) + $idx(first+1)}]
          set idx(next-1) [expr {$idx(next) - 1}]
          set scheme [string range $baseurl 0 $idx(first)]
          set host [string range $baseurl $idx(first+1) $idx(next-1)]
          if {[string compare $scheme http:/] == 0} {
            set scheme ht‌tp://
          } else {
            set scheme ht‌tps://
          }
          set logourl $scheme$host/
        } else {
          set logourl $baseurl
        }
      } else {
        set logourl $baseurl
      }
      return $logourl
    }
    set logourl [getLogoUrl $baseurl]
    </th1>
    <a href="$logourl">
      <img src="$logo_image_url" border="0" alt="$<project_name>">
    </a>
  </div>
  <div class="title">
    <h1>$<project_name></h1>
    <span class="page-title">$<title></span>
  </div>
  <div class="status">
    <th1>
      if {[info exists login]} {
        html "<a href='$home/login'>$login</a>\n"
      } else {
        html "<a href='$home/login'>Login</a>\n"
      }
    </th1>
  </div>
</header>
<nav class="mainmenu" title="Main Menu">
  <th1>
    html "<a id='hbbtn' href='$home/sitemap' aria-label='Site Map'>&#9776;</a>"
    builtin_request_js hbmenu.js
    foreach {name url expr class} $mainmenu {
      if {![capexpr $expr]} continue
      if {[string match /* $url]} {
        if {[string match $url\[/?#\]* /$current_page/]} {
          set class "active $class"
        }
        set url $home$url
      }
      html "<a href='$url' class='$class'>$name</a>\n"
    }
  </th1>
</nav>
<nav id="hbdrop" class='hbdrop' title="sitemap"></nav>
<h1 class="page-title">$<title></h1>

Updated state manipulation

  • Added a table-patch function that can update part of the state without requiring us to write out all of the fields
  • Would be nice to update it to allow us to add missing keys
  • It's recursive!
Download The Clock Tower
Leave a comment