Engine architecture
Prerequisite: How the game works.
This doc is about this project — how the TypeScript code is organised — as opposed to the game's file formats (those get their own docs). If you want to find where something lives in the source, start here.
Two layers: "read the files" vs "run the game"
The code splits cleanly in two, and it's worth keeping the split in your head because the two halves came from very different places.
src/df/ — the format library ("how to read the files")
This is a faithful TypeScript port of the decoding logic in DFET. Its only job is: given the raw bytes of a game file, produce plain data structures — a list of scenes, a decoded image, a palette, a chunk of audio samples. It knows nothing about how the game plays. Every file in here corresponds to a format doc:
| File | Reads | Doc |
|---|---|---|
container.ts | the shared container skeleton | DFile container |
binary.ts | low-level byte reading (endianness, strings) | DFile container |
image.ts | compressed frames, palettes, depth maps | Image codec |
set.ts | SET rooms/scenes/views | SET |
set-patch.ts | the SET write path — the copy-on-write patches the set editor makes, kept out of the reader the runtime loads | SET |
shp.ts | SHP props | SHP |
mov.ts | MOV movies — the segment chain, and the patches the movie editor writes | MOV |
mov-pace.ts | how fast a movie plays, in one place so the player and the editor cannot disagree | MOV |
stg.ts | STG stage/UI | STG |
cst.ts | CST casts (actor sprite sets) | PUP / CST |
pup.ts | PUP puppets (conversation close-ups) | PUP / CST |
audio.ts | TRK/SFX/11K audio banks | Audio |
banks.ts | shared chunk-directory walk for TRK/MOV banks | Audio |
script.ts | the compiled-script binary | Script container |
script-asm.ts | the way back: source text → the tokens encodeScript writes | Script container |
opcodes.ts | the opcode-ID → name table the script decoder and the disassembly tools share | Script container |
savegame.ts | .ti saved games (read, decode, patch-write) | Savegame |
text.ts | the character set the text bytes are in — which no DF file declares, so it comes from the language tree | Languages |
build.ts + *-build.ts | the write half: a container accumulator and the field writers, plus one builder per format (set, shp, stg, mov, pup, cst, banks) | Writing one back |
src/engine/ — the runtime ("how the game behaves")
This is the part DFET never needed and never had: the actual game engine. Its behaviour was reconstructed by watching the real game and by disassembling TI.EXE. Key files:
| File | Responsibility |
|---|---|
parser.ts | turns a decoded script's tokens into a syntax tree |
ast.ts | the syntax-tree node types the parser emits and the interpreter walks |
interp.ts | the interpreter — runs the scripts; owns the builtin registry |
builtins/ | the engine commands, grouped by family — core (pure language helpers), dispatch (the sendto* special forms), scene, props, audio, timing, actors, puppets, pointer, helpers, savegame — with context.ts as the shared plumbing and index.ts registering everything against a session (duplicates throw). The full command inventory is the builtin reference |
setscripts.ts | binds one SET's scripts to the interpreter and routes the event chain |
props.ts | the prop runtime — visibility, animation, compositing |
actors.ts | the actor (CST) runtime — walking characters and their poses |
geometry.ts | shared 3D math — projection, depth, occlusion, compass bearing |
audio.ts | playback channels (sound / voice / theme) and the sound library |
session.ts | GameSession — ties everything together and owns cross-set state |
clock.ts | the fine time base behind delay(n) waits |
scheduler.ts | the heartbeat — loops (makeloop), crickets, walks, looping sounds |
puppet.ts | PuppetController — PUP conversation close-ups |
stage.ts | StageController — the STG UI band / full-screen screens |
saveload.ts | saving/loading .ti games at the session level (what goes in, how a load restores) |
point.ts | the packed-point format (x<<16)|y scripts pass coordinates in |
input.ts | the event queue — input made while the engine was mid-gesture, and what flushevents() discards (recovered from the binary) |
bootplan.ts | what a game's boot needs, read out of its own BOOTFILE — the resource list, the landing room and the disc volumes that used to be hardcoded TAOOT filenames in the host (the boot plan) |
rng.ts | the seedable source behind the session's two streams — script random() and the engine's own ambient draws — which is what makes a run reproducible (why two) |
trace.ts | the state snapshot a playthrough asserts at each story beat |
The Scripting doc covers the interpreter and builtins in detail; the rest of this doc is about how the pieces run together.
src/ — the browser host ("put it on a screen")
Everything directly under src/ is the part that is neither format knowledge nor recovered engine behaviour: it hosts the engine in a browser page. If you are reading the code for the first time, these are the two files you will open first — and the two biggest, so here is the map:
| File | Responsibility |
|---|---|
main.ts | the page: audio unlock, DOM wiring, the session + its host hooks, set activation, the automatic cold boot, input handlers, the rAF loop |
host.ts | GameHost — what it means to run the game (set activation, prefetch, cold boot, resuming a save) with no reference to document; see the browser host |
viewer.ts | SetViewer — the navigation state machine over a parsed SET (turn/walk/teleport, hit-testing, the click priority chain, rendering) |
screen-presenter.ts | ScreenPresenter — the single persistent framebuffer every render path composites into, the fade overlays, and the signature check that skips a composite when the picture has not changed. Held by the host, so it outlives the viewer a set change replaces |
ring-cache.ts | the LRU of decoded turn/walk rings, on a byte budget — the viewer's memory story, on its own |
movie-player.ts | MoviePlayer — modal MOV playback: the segment chain, cutscenes, interactive close-ups, movie chains/calls, cues and the soundtrack |
puppet-view.ts | PuppetView — draws conversation close-ups (layer compositing, subtitles, choice bevels); the conversation logic is engine/puppet.ts |
files.ts | FileStore — every game file by lowercase basename, with lazy dev-server fetching |
screen.ts | the screen contract: 512×384, and where a SET view sits inside it |
fonts.ts | the canvas font stacks and wrapText — including breaking a line that has no spaces in it |
languages.ts / lang-chooser.ts / lang-menu.ts | the language axis: the table (codes, endonyms, code pages), the authored lang.stg chooser, and the 🌐 picker in the page's top bar |
editions.ts / collection.ts / booklet.ts / home.ts | the site around the game rather than the game: the shared edition control (one choice carried by the play page, the editors and /collection/), and /collection/'s own turnable box and 32-page booklet |
save-browser.ts / save-store.ts / save-seed.ts | the saved-games UI, its IndexedDB "file system", and the one-time seeding from shipped saves |
The GameSession: the thing that persists
GameSession (session.ts) is the top of the runtime. There is one interpreter whose variables live for the whole play session, so your inventory and progress survive when you walk from one set to another. The session owns:
- the interpreter and its global variables,
- the currently open SET (via a per-set binding,
SetScripts), - the audio banks and playback channels,
- the props runtime and the open "shop" (SHP) files,
- the stage layer (STG) — the UI band and any full-screen screen.
When you travel to a new set, the set is swapped out but the session stays. That's the key architectural fact: sets are disposable, the session is not.
GameSession used to be one very large class. The cohesive sub-runtimes have since been extracted into their own files — Clock, Scheduler, PuppetController, StageController — which the session composes (this.scheduler, this.stageCtrl, …).
For a while it also forwarded to them: a session.makeLoop(...) that called this.scheduler.makeLoop(...), and forty more like it, so that nothing outside had to know the extraction had happened. Those are gone. A caller addresses the subsystem it means — session.scheduler.makeLoop(...), session.stageCtrl.gotoFlat(...) — because a forwarder is a second name for one thing, and a second name is somewhere for the two to drift apart. What stays on the session is what genuinely belongs to it: the interpreter, the cross-set state, and the fields more than one subsystem reads.
The render picture: layers on a 512×384 screen
The screen is 512×384. It is drawn back-to-front:
┌────────────────────────────────┐ y = 0
│ │
│ the current SET view │ ← 512 × 264, only when "set visible"
│ (a pre-rendered background) │
│ │
├────────────────────────────────┤ y = 264
│ UI band: menu, held item, │ ← STG flat image + house.shp props
│ watch (STG + SHP props) │
└────────────────────────────────┘ y = 384- A stage flat image (from an STG file) is the bottom layer / background.
- The current SET view is composited into the top 512×264 (when the set is visible — full-screen screens like the map hide it).
- Props (SHP) are drawn on top, ordered by depth so nearer things cover farther ones. In-world props are placed using the 3D projection recovered from
TI.EXE; UI-band props sit at fixed screen positions.
How one mouse click flows through the system
This is the single most useful thing to understand, because the same "event travels down a chain" idea appears everywhere.
The diagram is the shape of the thing; the routing is the game's own, not the engine's. BOOTFILE 0001's mousedown is a hittest and a switch on result() into the six sendto* paths, and where a title ships that handler it decides where a click goes — the port's transcription is the fallback for one that doesn't (see the click priority chain).
The chain for a pointer event over a hotspot is object → scene → set main → stage, and off the end of it the event keeps climbing the containment chain — the file that holds the thing. Each level either handles the event (with an exitcode) or passes it on (passcode, or simply having no handler). The full event model is in the Scripting doc.
A keyboard event is dispatched differently, in two ways that matter.
It starts at the boot, not at the scene: TAOOT's boot holds a keydown that is a router — it maps the player's own movement keys (keynorth/keywest/keyeast, W/A/D by default and rebindable from the control panel) onto the arrows and then re-routes with sendtoscene(currentscene(), keydown(arg)). Everything else the press reaches, it reaches along that re-route, which is what carries the mapped value: scene → set main → stage → the boot library's own keydown, the default that turns "leftarrow" into currentscene("left"). Dispatching the boot's two containers side by side instead handed the default the key the player actually pressed, so the arrows worked and the W/A/D bindings did nothing at all (#14).
That mapping is a script, so everything above it is key-blind — and the event queue is above it. TI.EXE posts the record in its window proc and pops it in the main loop, both of them before any script has said what the key means, so a press made mid-move waits its turn whether the player made it with an arrow or with the letter they bound. The port kept its queue in the arrow path instead, one level below where the original keeps it, and the letters were dropped while the arrows were kept (#207).
And a link that merely finishes does not end the walk — only exitcode does. deckbd.set's keydown is the proof: a ladder of if currentview() = "viewNN" & arg = "uparrow" … exitcode that falls off the end for every other key. Under the pointer event's rule that would consume the press, and no arrow would ever reach the default movement. (This is also why a scene script can quietly steal ↑ to send you through a door instead of walking — it takes the key with an exitcode.)
What keeps the router from resolving its own re-route back into itself is a re-entrancy check: a script already running a handler further up the dispatch stack is never given it again. Before that existed the boot had to be kept off every fallback list, and reaching it was an out-of-memory rather than a wrong answer.
The boot is also where a title keeps its defaults, which is the other half of why events walk that far. They are written against target rather than me, because the boot is answering on something else's behalf — initprop hides a prop and zeroes it, resetactor disowns an actor:
code initprop () code resetactor ()
propvisible (target, false) actorowner (target, "none")
propvalue (target, 0) actorvalue (target, 0)
propdeg (target, 0) initactor ()Almost everything relies on them: of the 72 props TAOOT's two always-open shops give you, only door and signs carry an initprop of their own, and no cast member in the tree carries a resetactor. So a prop or an actor answering nothing for an event is the normal case, not the broken one — and a stub target, with no script at all, still has to reach them. TAOOT ships one: the purser is an actor record with an eight-byte script container, and dropping his events as "target not loaded" is what left him holding the cufflink into the next game (#89).
And a press may never reach the chain at all, because two things are modal ahead of it: a playing movie and a suspended conversation. Both are places where the original's own wait loop is the one popping the event queue, so the key is answered there — ESC aborts the clip, ESC skips the line — and the scripts are never told (host, conversations).
The heartbeat and timed events
The engine has a heartbeat that ticks 20 times a second — one service step every 50 ms, which is TI.EXE's own rate. On each tick it services timed things: script-scheduled callbacks (makeloop), positional ambient sounds (makecricket), and looping sounds. This is the job of scheduler.ts. A separate, finer time base (1 tick = 1/60 s) in clock.ts drives delay(n) waits.
This timing layer is fully recovered from TI.EXE and implemented; the write-up is Timing. The two headline facts:
- A "loop" is really a one-shot delayed callback — things appear to loop only because their handler re-schedules itself at the end.
- A "cricket" is a positional ambient one-shot bound to the current set, with stereo panning based on where it is relative to the camera; setting it to fire again with no gap makes a seamless loop, with a gap makes an intermittent hiss.
The runtime subsystems each have their own deep-dive under Runtime — timing, the stage layer, characters, audio playback, and saving/loading.
Running and verifying
npm run dev— dev server; the front page has a Play button and/play/cold boots itself into the game, with nothing in front of it but the boot text. The saved-games browser reaches saves from the in-game menu and the editors reach every.SETundergamefiles/; the dev harness that used to sit beside them — story-state presets, puzzle-jump buttons — is gone, and what it was for is the playthrough's job. Assets are fetched on demand. See the browser host.npm test— 302 Vitest tests: the end-to-end regression scenarios, savegame round-trips, recovered-builtin checks, the blackjack interpreter test, the editors' write path, the authoring and language suites, and the text/audio encoding ones. Prefer extendingregression.tsover writing throwaway tests. The full map is the test reference.npm run test:playthrough— the game played from the boot to the ending, 27 segments carried as one session, asserting a recorded state trace per beat. What that buys, and the bugs it has caught that nothing else could, is how we know it's right.tools/has standalone dumpers (dumpset,dumpshp,dumpaudio,dumpscripts), theTI.EXEmining tools (exetable,disasmcmd,scancmds) and the flow-map generator — see the tool reference. The dev server also hosts the seven browser editors, one per container format.
Now that you know where things live, the two deep topics are the scripting language and, underneath all the data, the DFile container format.
