Combat in a roguelike is a dice roll and a subtraction. It works in an afternoon. The line of text that tells the player what happened takes longer than that, and I'm still not done with mine.
Rogue had one line at the top of the screen. NetHack kept the line and added --More--, the prompt that stops everything until you press a key because the next message won't fit. It's forty years old and people still argue about it. Let's go through what that line has to do.
Grammar
The same event has to read differently depending on who did it to whom. You hit the orc. The orc hits you. The orc hits the kobold. Three sentences, one event, and the verb changes form with the subject.
Then names. An orc is "the orc", the first time you see it maybe "an orc", and a unique is just "Grimtooth" with no article. Then plural monsters, then "something" for an attacker you can't see, then "itself". Every one of these is a special case somebody will hit on floor three.
// src/ui/sentence.ts
export type Actor = { name: string; isPlayer: boolean; unique?: boolean };
// "You hit the orc." "The orc hits you." "The orc hits the kobold."
export function sentence(subject: Actor, verb: string, object?: Actor): string {
const subj = subject.isPlayer ? 'You' : capitalise(named(subject));
const verbForm = subject.isPlayer ? verb : thirdPerson(verb);
const obj = object ? ` ${object.isPlayer ? 'you' : named(object)}` : '';
return `${subj} ${verbForm}${obj}.`;
}
function named(actor: Actor): string {
return actor.unique ? actor.name : `the ${actor.name}`;
}
function capitalise(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
const IRREGULAR: Record<string, string> = { have: 'has', be: 'is' };
// hit becomes hits, miss becomes misses, fly becomes flies, and the list covers the rest
function thirdPerson(verb: string): string {
if (IRREGULAR[verb]) return IRREGULAR[verb];
if (/(s|sh|ch|x|z|o)$/.test(verb)) return `${verb}es`;
if (/[^aeiou]y$/.test(verb)) return `${verb.slice(0, -1)}ies`;
return `${verb}s`;
}
The regular verbs are rules and the irregular ones are a list, and the list grows for as long as you add verbs. I keep the verb in the attack data, so a dagger stabs and a club bashes and the sentence function never changes.
Who saw it
A message is only a message if the player could see it happen. The orc hits the kobold in a dark corridor and the player gets nothing, or gets "You hear fighting." The check belongs in one place, before the sentence is built: if neither the subject nor the object is visible, drop it or swap it for a sound. Forget this once and the player learns where every monster on the level is by reading the log.
Repeats
Ten misses in a row is ten identical lines, and the one line that mattered in between scrolls away. Merge repeats, but only within the same turn. "The orc misses you. (x3)" is fine, the same count across three turns hides how long the fight was.
// src/ui/log.ts
type Entry = { text: string; turn: number; count: number; important: boolean };
export class Log {
private entries: Entry[] = [];
add(text: string, turn: number, important = false): void {
const last = this.entries.at(-1);
// The same line again in the same turn becomes a count, not a new line
if (last && last.text === text && last.turn === turn) {
last.count++;
return;
}
this.entries.push({ text, turn, count: 1, important });
}
// The last few lines, newest last, with the current turn marked so the UI can colour it
recent(limit: number, currentTurn: number): Array<{ text: string; current: boolean; important: boolean }> {
return this.entries.slice(-limit).map((entry) => ({
text: entry.count > 1 ? `${entry.text} (x${entry.count})` : entry.text,
current: entry.turn === currentTurn,
important: entry.important,
}));
}
}
The turn number is the key. Messages from the turn the player just took show in full colour, older ones fade to grey, and that grouping tells the player what happened since their last key without a prompt at all.
The ones that stop the game
Some messages must not scroll away. You feel weaker. The floor shakes. Your hit points are low. NetHack's --More-- is the blunt tool for this. DCSS keeps a log panel and adds force_more_message, an option where you list the messages that should stop the game for you. That option is a design confession, and an honest one: the game can't know which messages matter to you, so it lets you say.
I do the smaller version. Important messages get a colour and the flag in the log, and the one message that must be seen, when something takes you below a health threshold, stops for a key, and that's the only stop.
Everything the player knows about a fight comes through the log. A combat system can be perfect and the game still feels random if the log says "You hit the orc" when the hit was for one point and when it was for twelve. Numbers in brackets, a verb that scales with the damage, something. The log has to carry the information the mechanics produce, or the mechanics might as well not exist.