I’ve noticed in some of the new development systems a switch in approach in a few areas and I was wondering what the community thought about the pros and cons to each approach:
Direct printing vs. messages for interpreter: this seems analogous to regular sound files vs. midi. I definitely understand the benefit when it comes to alternative languages and editability, but Inform already has most of that benefit with it’s “Responses” system. Are there any drawbacks? Does it limit writers in flexibility?
Traits vs. Kinds: To me, having a kind hierarchy is important for clear organization of the model world, but I certainly understand the limitation when it comes to kinds that should share aspects of multiple parent kinds. Is your vehicle a container or a supporter? Issues like that come up all the time.
Splitting actions based on number of objects vs. not: Throwing it at is an action applying to two things seems to me one of the core basics for defining an action, but why does throwing need to be different because you specified what it was directed at. Is undirected throwing really more like dropping?
I’m interested in hearing what people have to say about these.
IF involves a whole lot of text, so the system should not make it hard to write text. Dialog, Twine, and Ink all take the approach that text is actually the default, and code is what has to be marked: the plain words hello world mean “print ‘hello world’ to the screen”, and any other meaning of those words (variables and functions and whatnot) requires special syntax.
That text is often intertwined with the game logic in intricate and complicated ways, so the system should not separate it too far from that logic. Early IF systems (T/SAL, Scott Adams) generally separated them for technical reasons: you’d have one file of text, and a separate file of code that referred to that text via numeric identifier. This was unpopular and the next generation of systems, on higher-end machines, quite deliberately changed it.
So for a “messages for the interpreter” design to win me over, it needs text to be easy to write and easy to intertwine with game logic. I haven’t yet seen one that does this. The message system definitely has its advantages—but I haven’t yet seen those advantages outweigh the drawbacks.
I think it’s telling that even Inform 7, the main example of a “kind-based language” (i.e. a language with single inheritance but not multiple inheritance), still relies on traits for things like “switchable” and “openable”. Doors and containers are disjoint categories, but both of them need to be potentially openable (and lockable and so on), therefore “openable” cannot also be a kind.
Inform 6, Dialog, ZIL, and I believe TADS all use traits and/or multiple inheritance for everything, so this problem never comes up.
I like how Inform gestures at actions with several optional qualifications, so you can write “instead of going north from the driveway by car”. But unfortunately it’s only a gesture; I would love to see an IF system that can handle this in a fully general way, for all sorts of actions. (Dialog, at present, absolutely can’t.)
One of the first core requirements in Sharpee was that text should be completely separate from the logic of the story. My original framing was that concatenating in-turn text and managing newlines and punctuation was a good enough reason, but after having it implemented, the benefits grew. Implementing multiple languages composes to copying two files (parser-en-us and lang-en-us) and updating them. The overall architecture is language-agnostic. Emitting domain event messages (‘if.action.taking’) and then post-turn organization of those messages allows me (or anyone) to design output in templated ways. The default template still matches Infocom, but that’s only because I coded it that way.
And the writer still gets control by adopting named phrases. They can write the story, embed named phrases, and then circle back to write the contents of those phrases. To me, this is an IDE problem, not a platform problem. I haven’t implemented it in Chord Writer yet, but inline phrase definitions are easily implemented. Adding story perspective, gender identity, articles, punctuation…all of these elements of IF were significantly easier by separating text from the story logic.
This is how Sharpee thinks through text emission:
### A. Order within one turn
`sortEventsForProse` — `packages/engine/src/prose-pipeline/stages/sort.ts`
turn events
│
├─ lifecycle first ──► game.started / starting / loading /
│ loaded / initialized
│ (banner rides here — contract rule 4)
│
└─ then transactions, in occurrence order (rule 3)
txn:{turn}:action ─► txn:{turn}:plugin:{id}, priority order
an ABSENT _transactionId never groups — not even with
another absent one (D1, closes GH #208 structurally)
inside one transaction — the slot frame (D2/D3):
┌ implicit-take fixture ─► if.event.implicit_take (always 1st)
│
├ beforeRoomDescription ─► _narrativeSlot stamped, opt-in
│
├ ANCHOR CLUSTER ────────► if.event.room.description
│ + if.event.list.contents…
│ (skips if.event.illustrated)
│
├ afterRoomDescription ──► chained phrases — DEFAULT slot
│
├ …unstamped events keep emission position (rule 1)…
│
└ afterEverything ───────► transaction-final
no anchor (TAKE, blocked move)? the frame collapses around
the primary report event = first event carrying data.messageId
### B. Blocks → channels → the default client
ADR-300 D8/D9
block key (CORE_BLOCK_KEYS) channel id
─────────────────────────── ──────────────────────────────────────
room.name ──► room-name
room.description ──► room-description
room.contents ──► room-contents
action.result ──► action-result
action.blocked ──► action-blocked
error ──► error
game.message ──► game-message
game.banner ──► banner (BANNER_KEYS; record-valued:
title, storyVersion,
platformVersion, credits[],
tail[])
status.room|score|turns ──► location / score / turn (world state,
not prose)
prompt ──► prompt
routing table: packages/stdlib/src/channels/keys.ts
┌──────────────────────────────────────────────────┐
all 7 │ preferred-layout (mode: replace, emit: always) │
────►│ ["room-name","room-description","room-contents", │
│ "game-message", …] │
│ one entry per prose entry emitted, in block order│
└──────────────────────────────────────────────────┘
default client — packages/platform-browser/src/channels/prose.ts
prose renderers BUFFER (never append on arrival)
│
▼
preferred-layout arrives LAST ─► composeProse(payload)
walks the layout list, taking each named channel's
NEXT unconsumed entry; a repeated id means that channel
emitted twice, and its cursor advances
│
▼
joinProseEntries: blank line between entries,
single \n when entry.tight
│
▼
one <p class="main-entry prose-{channel}"> per entry
### Trip-wires
`preferred-layout` **must** be registered after the prose channels
(stdlib's `STANDARD_CHANNELS` does, pinned by a test) or the flush fires
against an empty buffer.
`main` no longer exists, so a new surface that ignores `preferred-layout`
renders in manifest order — wrong the moment an action result precedes a
room name.
`messageId` is not an ordering key. It does two jobs: it marks an event
as phrase-bearing (the anchor-less collapse point in A), and it is the
ADR-296 D4 partition — a `game.message` against a trigger that already
has a `messageId` stays an ADR-106 override rendering at the trigger's
position; against a messageless trigger it becomes a standalone,
slot-placed phrase.
Composition has enormous power in an IF system. Inheritance is certainly a legitimate implementation and this really is a choice, not “one is better than the other”. But to me, Traits follow modern software engineering practices which tells us to model the behavior and data in a way that’s composable. I can’t count how many times, when implementing a sub-system, the Traits and Behaviors architecture has given me logic for free.
### A. The shape: one entity class, a trait map
IFEntity ── the ONLY entity class (no Room/Door/Container subclasses)
│
├─ id, type
└─ traits: Map<TraitType, ITrait>
│
├─ 'container' ─► ContainerTrait { capacity, isTransparent,
│ enterable, allowedTypes… }
├─ 'openable' ─► OpenableTrait { isOpen, … }
├─ 'lockable' ─► LockableTrait { isLocked, keyId, … }
└─ 'identity' ─► IdentityTrait { name, description, … }
API: has / hasAll / hasAny / get / add / remove / getTraits
a locked chest is not a subclass of anything —
entity.add(new ContainerTrait())
.add(new OpenableTrait())
.add(new LockableTrait())
.add(new IdentityTrait())
what inheritance would have forced instead:
Item
└ Container
└ OpenableContainer
└ LockableOpenableContainer
…and a lockable DOOR needs those same three from a
different root, so the tree forks or duplicates
### B. Data / logic / coordination — the three-layer split
DATA LOGIC COORDINATION
─────────────────── ───────────────────── ──────────────────
ContainerTrait ContainerBehavior taking action
implements ITrait extends Behavior (stdlib)
static readonly type static requiredTraits validate / execute
readonly type = [CONTAINER] / report / blocked
static methods only
fields only — OWNS every mutation owns NO mutation;
NO methods to ContainerTrait calls behaviors and
returns result objects emits events
(IAddItemResult…)
traits/container/containerTrait.ts ← data
traits/container/containerBehavior.ts ← logic (co-located)
behaviors/behavior.ts ← the shared base
`Behavior` gives subclasses only `require()` (throws on missing trait),
`optional()`, `validateEntity()`, `getMissingTraits()` — trait access
helpers, not domain logic.
### C. Dispatch: how an action finds the right trait
trait declares what it answers for (ADR-090):
class TrollAxeTrait implements ITrait {
static readonly capabilities = ['if.action.taking']
static readonly interceptors = [ … ] ← ADR-118
}
action asks, before running its standard logic:
findTraitWithCapability(entity, IFActions.TAKING)
│
└─► walks entity.traits.values()
reads trait.constructor.capabilities
first match wins → undefined means "no claim,
run the standard path"
world-model/src/capabilities/capability-helpers.ts
also: hasCapability(entity, actionId)
so a story adds a verb-blocking behaviour by adding a TRAIT,
never by subclassing or patching the action
### D. Where inheritance is used on purpose — two places
Behavior (abstract) ← 21 subclasses in world-model.
Shares trait-access helpers only;
no domain state, no overridden logic.
WallEntity extends IFEntity ← exactly 1, ADR-173. A wall is a
two-room adjacency with per-side data
keyed by the room you see it from —
structure a trait map can't express.
Authors never construct it; they call
WorldModel.createWall.
### E. The registration cost of composition
A new trait must be exported at three barrel levels or you get a runtime
`"X is not a constructor"`:
traits/<your-trait>/index.ts ─► traits/index.ts ─► src/index.ts
then rebuild dist/ AND dist-esm/
The trade the architecture is making: composition moves the cost from
*design time* (getting a hierarchy right before you know the game) to
*registration time* (three barrel lines, mechanical, caught by a loud
runtime error). The `is*` convenience getters on `IFEntity`
(`isContainer`, `isLockable`, `isOpen`…) are sugar over `traits.has()` —
they read like a type test but never imply a class.
This is exactly where Traits are so helpful. I can add the ThrowableTrait at anything and it absorbs the throwing action logic for free. If it’s a new action, I create the action and its behavior and apply it to whatever objects it needs.
Inform 7 declares a two-noun action in one place:
Throwing it at is an action applying to two things.
Understand "throw [something] at [something]" as throwing it at.
Check throwing it at: ...
Carry out throwing it at: ...
Report throwing it at: ...
Sharpee splits that across five layers, each owned by a different
package. Traced end to end for `throw bottle at troll`.
### A. The pipeline
> throw bottle at troll
│
▼
GRAMMAR — packages/parser-en-us/src/grammar.ts
grammar.define("throw :item at :target")
.mapsTo("if.action.throwing").build()
the "Understand" line. 9 patterns map to this one action:
throw/toss/hurl × (bare | at :target | to :recipient)
│
▼
PARSER — packages/parser-en-us/src/english-parser.ts
slot → object assignment, by POSITION not by name:
slot position 0 ──► directObject (:item = "bottle")
slot position 1 ──► indirectObject (:target = "troll")
.instrument("x") ──► instrument slot, never becomes an object
anything further ──► extras{} (throwing reads extras.direction)
a handful of patterns are named special cases ahead of the
positional rule (give/show recipient-first, :item from :container)
│
▼
VALIDATOR — packages/stdlib/src/validation/command-validator.ts
resolves each noun phrase to an entity at that SLOT'S scope level,
read from the action's own metadata
│
▼
ACTION metadata — this is "applying to two things"
metadata: {
requiresDirectObject: true,
requiresIndirectObject: true,
directObjectScope: ScopeLevel.REACHABLE, // allows implicit take
indirectObjectScope: ScopeLevel.VISIBLE // throw at what you
} // cannot reach
The two slots carry **different** scope levels — the thing you throw
must be reachable (so `throw bottle at troll` can implicitly take the
bottle off the floor), the thing you throw at need only be visible.
One I7-style "applying to two things" cannot say that; two independent
slot declarations can.
### B. Check / Carry out / Report → the four phases
Inform 7 Sharpee (ADR-051)
──────────────────────── ────────────────────────────────────────
Check throwing it at validate(ctx): ValidationResult
reads BOTH slots; returns
{ valid: false, error: 'target_not_here',
params: { target } }
Carry out throwing it at execute(ctx): void
mutates the world, fills sharedData
(throwType, willBreak, finalLocation…)
Report throwing it at report(ctx): ISemanticEvent[]
emits if.event.thrown carrying
messageId `if.action.throwing.thrown_at`
Instead of throwing… blocked(ctx): runs when validate failed
`validate` is where "two things" becomes conditional rather than
structural: `item` is required (`no_item` otherwise), while `target` is
optional — absent it, the action falls through to a directional throw
(`extras.direction`) or a general one. So the same action id serves
`throw bottle at troll`, `throw bottle north`, and `throw bottle`.
### C. Where a story hooks in — without touching the action
1. CAPABILITY DISPATCH (ADR-090) — the target claims the verb
findTraitWithCapability(target, IFActions.THROWING)
→ the trait's behavior runs execute AND report instead
2. INTERCEPTOR LIFECYCLE (ADR-228) — both slots are consulted
throwingLifecycle.slots:
┌ 'item' ──► ctx.command.directObject?.entity
└ 'target' ──► ctx.command.indirectObject?.entity
each seeded with { itemId, itemName, targetId, targetName },
direct object FIRST (D3-B order). Not a single winner: an
explosive can react to being thrown AND a glacier can react
to being hit, in the same command.
hooks: preValidate / postValidate / postExecute / postReport
/ onBlocked
3. EVENT HANDLERS (ADR-052) — react to if.event.thrown after the fact
### D. Text
The action declares 24 message ids in `requiredMessages` (`thrown_at`,
`hits_target`, `misses_target`, `bounces_off`, `target_ducks`,
`target_catches`, `breaks_against`…). It never writes a sentence —
`report` puts `if.action.throwing.<id>` plus params on the event, and
`@sharpee/lang-en-us` renders it.
### E. The count
One I7 declaration ≈ five Sharpee edit sites:
parser-en-us/src/grammar.ts the patterns
stdlib/src/actions/constants.ts the action id
stdlib/…/throwing/throwing.ts metadata + four phases
stdlib/…/throwing/throwing-events.ts the event payload types
lang-en-us every message id
That is the cost. What it buys: per-slot scope, per-slot interceptors,
a typed event payload other systems can read, and text that lives in
one locale package rather than inside the rule that fired it.