Inspiration

Every UniMelb student knows the feeling. You step off the tram at Swanston Street — just one stop outside the Free Tram Zone — and there they are: a cluster of yellow vests blocking the exit. Ticket inspectors. Again.

Melbourne tram fares are notoriously expensive and the zone boundaries feel deliberately confusing. Swanston Street stop, right at the front gate of the University of Melbourne, sits just outside the Free Zone. It is, by some accounts, the single most-inspected tram stop in the city. Ellitt and Nannan have both been caught there. Multiple times. The $226 fine each time stings less than the principle of it.

One night, instead of writing their assignment (ironic, given the "Due Deadline Rage" skill in the game), they decided to turn that shared frustration into something playable — a love letter to every student who has ever sprinted through a tram station hoping the doors close before the inspector reaches them.

What it does

Escape from UniMelb Tram Station is a pixel-art survival game rendered entirely in an HTML5 Canvas. The player controls a UniMelb student on a vertically-scrolling tram platform and must survive 5 rounds of 30 seconds each, with the pressure increasing every round:

  • Round progression — Each new round spawns +1 ticket inspector and +2 civilian NPCs, and every entity (player, inspectors, crowd) moves faster.
  • Skills — Before each round the player picks one extra ability:
    • 🥷 Disguise (always available, SPACE) — blend into the NPC crowd for 5 seconds; inspectors wander aimlessly with a flashing "?" above their heads.
    • Myki Card — absorb one catch for free, as if you'd tapped on all along.
    • 🔥 Due Deadline Rage — channel thesis-deadline fury to push civilians out of your path (inspectors are immune; they've seen it all before).
  • LLM Interrogation — Getting caught isn't an instant game over. The game transitions into a live interrogation: Claude (claude-haiku) plays the inspector and questions you about your missing tap-on. You type your excuse; the model evaluates its logic, creativity, and plausibility, and returns one of three verdicts — released, fined ($226), or arrested — with a fully in-character two-sentence response. A good enough story and you're back in the game with 8 seconds of grace.

How we built it

The entire game runs in a single self-contained tram_dodger.html file with no external dependencies for the game itself:

  • Rendering — HTML5 Canvas 2D API with imageSmoothingEnabled = false for crisp pixel art. An 11 × 22 tile grid (TILE = 36 px) forms the map.
  • Pathfinding — A custom A* implementation on the tile grid drives inspector chase behaviour. Inspectors recalculate their path every 28 frames or whenever the path is exhausted. During player disguise, A* is replaced by random-walk wander logic so inspectors feel genuinely confused.
  • BGM — A looping chiptune melody composed and played entirely through the Web Audio API (oscillators only — no audio files), so the game has zero external assets.
  • LLM integration — A lightweight Express.js server (server.js) proxies POST requests from the game to the Anthropic Messages API. The call is fully async; the game loop never blocks. The model is prompted to respond strictly in JSON ({ "verdict": "...", "message": "...", "reason": "..." }), with a regex fallback to extract JSON even if the model adds surrounding text, and a silent catch-block that defaults to "fined" if the request times out or fails — so the game degrades gracefully even offline. Player caught │ ▼ startInterrogation() ──► Inspector question rendered on canvas │ ▼ Player types excuse (HTML <input> overlay) │ Enter key ▼ fetch('/api/interrogate') ──async──► Express server ──► Claude Haiku │ │ │ ◄──────────────── { verdict, message, reason } ◄───────┘ ▼ Verdict rendered ──► released → resume game | fined/arrested → game over

Challenges we ran into

  • A* on a live tilemap — Door states change in real time (tram doors open and close on a schedule), so the walkability function passed to A* had to be time-aware. Getting inspectors to pathfind through an open door but not a closed one took several iterations.
  • Web Audio autoplay policy — Modern browsers block audio context creation until a user gesture. We had to defer initAudio() to the first keydown event and use a scheduled callback chain (setTimeout) instead of a single looping AudioBufferSourceNode, because the latter requires a pre-loaded buffer.
  • LLM output consistency — Claude occasionally wraps its JSON in markdown fences or adds a preamble sentence. We solved this with a /\{[\s\S]*\}/ regex to extract the first JSON object from any response, making the parser robust without forcing strict output mode at the cost of latency.
  • Canvas font sizing at tiny scales — Tile-internal labels (door signs, ad boards, the "INSPECTOR" badge) sit inside a 36 × 36 px tile. Increasing UI font sizes while keeping in-world text small enough to fit required careful separation between "game-world text" and "UI overlay text" — two completely different size budgets.
  • Async LLM in a synchronous game loop — The requestAnimationFrame loop runs every ~16 ms but the LLM response takes 500–1500 ms. We used a simple state-machine (phase: 'typing' | 'thinking' | 'verdict') so the game loop just renders the current phase and never awaits anything.

Accomplishments that we're proud of

  • A complete, polished pixel-art game built entirely from scratch in HTML5 Canvas with zero external game libraries.
  • A seamless handoff between classical AI (BFS / A* pathfinding) and generative AI (Claude) at exactly the highest-tension moment of the game — when the player is caught. The transition feels dramatically earned rather than bolted on.
  • A BGM that sounds like a real chiptune but is generated purely from oscillator math — no files, no CDN, no latency.
  • The interrogation system genuinely changes player behaviour: people start typing increasingly creative excuses just to see what the inspector says, turning a loss state into a replayable micro-game in itself.

What we learned

  • A* pathfinding is remarkably tractable at small grid sizes — the entire implementation fits in ~40 lines and runs comfortably within a 16 ms frame budget for up to 5 simultaneous agents.
  • Prompting an LLM to return strict JSON while also staying "in character" is a balance: too much constraint and the response feels robotic; too little and the parser breaks. A regex extraction fallback is essential insurance.
  • The Web Audio API is a surprisingly complete music synthesis environment. A convincing chiptune requires only square and sawtooth oscillators, a gain envelope, and a scheduler — roughly 30 lines of code.
  • Separating the "game-over trigger" (pathfinding catch) from the "game-over consequence" (LLM verdict) into two distinct systems made both far easier to test and debug independently.

What's next for Escape from UniMelb Tram Station

  • Multiplayer — Two players on the same screen: one student, one inspector controlled by a second human (or a more aggressive LLM agent).
  • Procedural map generation — Different Melbourne tram stops as selectable stages, each with different geometry and inspector patrol patterns.
  • LLM inspector memory — The inspector remembers previous excuses across rounds. Try using the same story twice.
  • Leaderboard of best excuses — Community-voted hall of fame for the most creative player responses that unlocked a "released" verdict.
  • Mobile touch controls — The game is almost entirely input-agnostic; a virtual D-pad overlay would make it fully playable on phones.

Built With

Share this project:

Updates