This is an experiment / proof-of-concept for a Trivia game written in Typescript (Powered by Bun).
It consists of:
- Trivia - A Game Engine (using the Open Trivia Database API)
- Event - A simple event system (with a log, and projector)
- Example - A simple example game using React (as well as tests showing a simple game loop)
src/
├── event/
│ ├── event.test.ts
│ ├── index.ts
│ └── types.ts
├── example/
│ ├── core.ts
│ ├── example-react-app/
│ ├── example.test.ts
│ ├── index.ts
│ └── types.ts
└── trivia/
├── index.ts
├── trivia.test.ts
└── types.ts
This idea was initially an exploration into creating a trivia game for Twitch streamers, where they can play with their chat. In this case, the approach of separating the game state from the game logic seemed a good fit. I've worked with CQRS before, and the experience of "replaying" the log to construct the current state was very appealing, in terms of syncing the game state across clients.
In practice, the trivia and event modules could be packaged into whatever sort of server - client relationship you want to build. Most obvious would be a Websocket server, but you could also use a REST API, or even a serverless function (given that writes and reads are separate).
Special shoutout to Oskar Dudycz's notes on Event Sourcing in Node for some great reading material on the subject.
It's worth noting that the OpenTDB has a rate limit which can be easily avoided using Bun's test filtering.
There are three tests - trivia
, event
, and example
- and these can simply be appended to bun test
to run each suite separately.
bun test trivia
bun test event
bun test example
The game uses a Trivia API from Open Trivia Database, which usefully offers parameters such as Cateogory, Difficulty and Question Types to fetch and prepare data for the game.
export type TriviaQuestion = {
id: string;
category: Category;
type: QuestionType;
difficulty: Difficulty;
question: string;
correct_answer: string;
incorrect_answers: Array<string>;
};
// Usage
const trivia = new Trivia(category, difficulty, type, amount);
const question = await trivia.getQuestions();
Orchestrating the instantiation, configuration and question fetching is the Event Sourcing system, which comprises a few components.
Events are the main data structure, holding the metadata for each event (making it possible to replay them), as well as the game data for re-hydration.
type Event = {
id: string;
position: string;
type: string;
date: number;
data: any; // Event data goes here
}
// Extending in-practice for your domain
type TypedEvent<T> = Event & {
data: T;
}
Next, we have the Event Log (and projector). This is a simple array of events, which can be pushed to, and hydrated from.
export interface IEventLog {
log: Event[]
position: number
create<T>(type: string, data: T, position?: number): Event & { data: T }
push(event: Event): void
pos(): number
project<T>(filters?: EventFilters): Array<TypedEvent<T>>
printEvent(e: Event): string
}
// Usage
const log = new EventLog();
log.push(log.create({ id: 1, type: "apple" }));
log.push(log.create({ id: 2, type: "pear" }));
const appleEvents = log.project({ type: "apple" });
console.log(appleEvents); // [ { id: 1, type: "apple" } ]
const pearEvents = log.project({ type: "pear" });
console.log(pearEvents); // [ { id: 2, type: "pear" } ]
As you can see, the event system is extensible, and easy to modify re-use in a custom implementation, eg. Redis, a database, or a file system. In practice, this can be used to hydrate state as in the following example:
const fruitLog = {
apples: 0,
pears: 0,
}
function hydrate() {
const events = log.project();
events.forEach(({ data: { type } }) => {
if (type === "apple") {
fruitLog.apples += 1;
} else if (type === "pear") {
fruitLog.pears += 1;
}
});
}
hydrate();
console.log(fruitLog); // { apples: 1, pears: 2 }
Exploring this project interactively.
The first example game is src/example/example.test.ts
. This uses a class IGameState
in src/example/core.ts
, which extends IEventLog
to add a number of fields to populate (ie. the "Game State"), as well as a hydrate
method which runs the entire projection, reconstructing game state values such as started_at
, score
, total
, etc.
Stepping through this test is a great reference for using these modules to implement your own game logic elsewhere.
Currently, the example is a simple single-player experience (open a pull request if you're interested in changing that!). It's a basic round of Trivia, with a live projection of the game state and event log so you can observe the events being logged, and corresponding state being updated as you play.
gh repo clone knightspore/trivia
cd trivia
bun install
bun run example