Create a new game

Build a Maketools game step by step on top of the common engine, starting from the sample game.

Developers21 min read

Every Maketools game plugs into the common engine (@dg/engine). The engine runs the game, provides everything it needs through a game host, and handles what is common to all games: play sessions, telemetry, timer, HUD, sounds, fork theme, editable zones, pause, end screen and accessibility.

The reference is the sample game in engine/examples/sample-game, a true/false quiz. Copy its structure: it is deliberately small, tested and kept up to date. A new game starts from the engine building blocks described here: before writing a timer, a test bench or key handling, look for the existing block; when something serves two games, it becomes an engine block.

1. Package layout

games/<id>/src/
├── core/            pure TypeScript, also imported by the backend
│   ├── config.ts        config schema (defineGameConfigSchema) and default config, in the source language
│   ├── translations/    one ContentTranslation per other language (fr.ts…)
│   ├── scoring.ts       replayable scoring (ScoringModule)
│   ├── editor-model.ts  editor model (entities, collections, screens, appearance)
│   └── definition.ts    GameCoreDefinition: manifest, schema, languages, zones, fieldLabels, editor, scoring, checkContent
└── ui/              Angular + Phaser
    ├── <id>-game.ts      root component (injects the host)
    ├── <id>.definition.ts   GameDefinition = core + loadUi() + loadEndSummary()
    └── scenes/           Phaser scenes, always lazy loaded
games/<id>/scripts/content-review.ts   content review report (bun run content:review)

core never imports Angular, Phaser or Encore: the backend uses it to validate fork configs and to recompute scores. ESLint enforces these boundaries.

2. Config and definition

Declare the config schema with defineGameConfigSchema({ major, rules, content }). Every text shown to the player lives in the config, so that an organization can change it in its fork. Give every scored item a stable id and a skillId.

What gets translated

A config is written in one language: its texts are plain strings. The game decides, field by field, what is translated:

  • contentText(maxLength) for every text the player reads (statement, label, explanation). The studio edits it in the fork's source language and puts it in the translation files (keyword x-dg-text in the JSON Schema);
  • z.string() for what is never translated: identifiers, data values compared by the scoring, codes.
const ItemSchema = z.object({
  id: z.string(),                // identifier: never translated
  skillId: SkillIdSchema,
  statement: contentText(300),   // shown to the player: translated
  answer: z.boolean(),
});

When in doubt, ask: "would a French player see this text in English?". If so, it is a contentText.

Languages of the game

The definition gives the language of defaultConfig in sourceLocale, and one translation per other language in translations. A translation is a ContentTranslation ({ locale, entries }) kept in src/core/translations/<locale>.ts. Each entry { id, source, target } has the same form as in a fork's translation file: id is the JSON pointer of the text, source its text in defaultConfig, target the translation.

// src/core/translations/fr.ts
export const frenchTranslation: ContentTranslation = {
  locale: 'fr',
  entries: [
    { id: '/content/intro', source: 'Spot the hallucinations.', target: 'Repérez les hallucinations.' },
    // …one entry per text of defaultConfig
  ],
};

// src/core/definition.ts
sourceLocale: 'en',
translations: [frenchTranslation],

The catalogue lists the game in its source language and in each translation (gameLocales(definition)), and the demo plays defaultConfigIn(definition, locale). When an organisation forks the game in one of these languages, the others become translations of its fork (forkStartingPoint). configTexts(definition, config) lists the translatable texts of a config, in document order.

checkGameDefinition fails when a shipped translation:

  • misses a text of defaultConfig: a shipped translation is complete, the demo never shows a text in the source language;
  • has an obsolete entry, whose source no longer matches the text at id: after changing the default content, update the translations in the same commit;
  • gives a translated config that fails configSchema or checkContent (for example, a passage that must appear in its text).
  • is written in a language that is not a content language, or repeats the source language.

Content languages (CONTENT_LOCALES, type ContentLocale): English, French, Spanish, Portuguese and German. They are not the languages of the site (Locale: English and French, for the interface, emails and reports). A game can be written in any content language and ship translations into the others; an organisation adds the missing ones to its fork with a translation file. The engine texts a player sees (HUD, pause, end screen, SKILL_LABELS) exist in every content language (PlayerText). The UiText of a game (manifest title and summary, interface texts written in the code) require en and fr and accept es, pt and de, falling back to English: a game shipped in German also writes de for what the player sees (the manifest title is shown on the end screen). Editor labels (fieldLabels) stay in English and French.

Editable zones and editor labels

The manifest carries the visuals shipped with the game: thumbnail, the cover without text (fork cards, whose title differs from the game), poster, optional, the catalogue tile with the game title drawn in the centre (16:10, 800 px), and ogImage, optional, the 1200 × 630 image shared by the game page on social networks and search engines. Without poster, the catalogue writes the title over the cover; without ogImage, the game page shares its poster. Generate ogImage from the poster with bun run og:images <game-id> (published or not: a game missing from the catalogue snapshot starts from games/<id>/assets/poster.webp, otherwise cover.webp): it writes games/<id>/assets/og.webp, deterministic, then declare it (ogImage: builtinAsset('og.webp')). Run it again whenever the poster changes.

It also gives releasedOn, the release date of the game (YYYY-MM-DD, the same for every major).

A new game arrives unpublished: registered in the registry, it has no catalogue page, no demo, no place in the ranking, and no org can fork it. What is exposed is decided in the database of the catalog service, never in the code: the Maketools team tests it by granting it to its internal org (prerelease), then publishes it from catalog curation (/curation). The date of its first publication drives the "New" badge (30 days); it has no reserved slot among the six featured games: it gets there through usage, or pinned to the top by a curator. An exceptional publication through a migration of the catalog database (bunx drizzle-kit generate --config catalog/drizzle.config.ts --custom --name publish_<id>, from backend/, modelled on 0002_publish_best_cdo.sql: one published game_listings row, listed_at set to the publication day, position at the end of the list) is also added to INITIAL_LISTINGS (a seed checked by a test), then bun run api:gen rewrites the catalogue snapshot (frontend/src/app/core/catalog/catalog-snapshot.gen.json, published games of the seed), which the CI compares with the code. At deployment, the snapshot is taken from the environment's API: a game published from curation enters it at the next deployment, and pages show it as soon as they load in the browser. A bespoke game (availability: 'private') is never published: it is granted org by org.

Nothing else to write for the site: from this snapshot, the game gets its own game page, prerendered in English and French (/games/<id>, /fr/games/<id>: poster, summary, skills, demo, structured data), a page per skill it trains that had none yet (/skills/<slug>), its entry in the sitemap and in llms.txt (bun run docs:build). Only two things are required, checked by bun run docs:check: the game guide docs/{en,fr}/games/<id>.md, which links to the game page (a link to /games/<id>), and a description of any new skill in the skills i18n space (frontend/public/i18n/{en,fr}/skills.json). Write the guide with the game, before it is published: while the game is missing from the snapshot, its guide is held back from the site (no page, no navigation entry, no sitemap entry), may already link to its game page and skill pages, and no published page may link to it; it goes live when the game is published. Then link it from the learn pages of the notions it trains.

Declare the editable zones (editableZones): JSON pointers into the config, with * for arrays, for example /content/items/*/statement.

Name every other field of the config in fieldLabels: the studio side panel takes all its labels from the game definition, and so does the context of each entry of a translation file ("Rounds › Round 1 › Title"). Labels, like the manifest title and summary, are interface texts written in the code (UiText), given in every language of the platform (English and French): they are not content to translate. The keys are JSON pointer patterns, with * for any array index or property:

fieldLabels: {
  '/rules/pointsPerItem': { en: 'Points per item', fr: 'Points par item' },
  '/content/items': { en: 'Items', fr: 'Items' },          // section
  '/content/items/*': { en: 'Item', fr: 'Item' },          // one element: "Item 3"
  '/content/items/*/skillId': { en: 'Skill', fr: 'Compétence' },
  '/content/items/*/kind': { en: 'Kind', fr: 'Type', options: { quiz: { en: 'Quiz', fr: 'Quiz' } } },
}
  • An editable zone already names the fields it covers: its label wins.
  • The common fields (BaseRules, theme, branding) are named by the contracts (BASE_FIELD_LABELS): do not repeat them.
  • Every value of an enumeration needs an options label, except skills (closed SkillId taxonomy).
  • A field kept until the next major but no longer used by the game gets deprecated: true: the studio hides it. Remove it for good with a content migration.

checkGameDefinition fails when a field, section or list element of the config schema has no label, when an option has no label, or when a fieldLabels key matches no field.

What the schema cannot express (unique ids, one right answer per item…) goes into the optional checkContent(config): string[] hook of the definition. checkGameDefinition runs it on the default config and on each shipped translation, and the studio on every draft and release: any problem returned blocks the save (ERR_CONFIG_INVALID).

Test the definition with expect(checkGameDefinition(definition)).toEqual([]).

Editor model

The panel generated from the schema follows the config tree: it is a developer's view. So that a training manager finds question 12 of round 3 without six expansions, the game describes its content as objects in editor (contracts EditorModel, file core/editor-model.ts, plans/08-editor.md). The studio builds a single content screen from it (searchable list, filters, table, bulk editing), separate from the screens' shell.

  • entities: the objects of the game, named and explained in one sentence from the player's point of view (name, plural, description, as UiText). For each entity: title (the text that names an element in a list, never an id), overline, idField (stable id, hidden), facets and columns (enumerations, booleans, enumeration arrays, texts of 60 characters at most, or derived:<id>; a column can also be a number, edited in its cell), derived (values computed by the core, like the right answer: same rule as the scoring), the record's sections, references to another part of the config (the character of a request: a text; the emails a reply triggers: an array of texts), create(config, id) (a valid new element). The fields of an entity are pointers relative to the element ('/text', '' = the element itself).
  • collections: where the elements live, as patterns ('/content/requests/*', or '/content/rounds/*/items/*' for the items of every round). A fixed-key reference list ('/content/redFlags/*') has a fixedReason: you write its texts, never the list. A nested collection takes its subtree away from the parent collection (a round's record does not show its items). views: ['graph'] adds the Graph view: the elements of the collection linked by the references (of any entity) that target them.
  • screens: the shell, in the order of the player's journey, each screen with its fields (titles, buttons, instructions).
  • appearance: the visuals of the content (scenery, characters), next to the theme and the branding. Rules, theme and branding have their own mode, with nothing to declare.

Placement rule: every leaf of /content has exactly one place, a collection element (its whole subtree), a screen field or an appearance pattern (subtree included). checkGameDefinition names every field left out or placed twice, and also checks ids (kebab-case, unique), patterns (they match a field), entity fields and their types, derived values on every element of the default config, create() (the element passes the schema and gets its id) and the references of the default config (each value names an existing element). A deprecated field may stay without a place.

Excerpt from AI Not AI:

export const aiNotAiEditor: EditorModel<AiNotAiConfig> = {
  entities: {
    request: {
      name: t('Request', 'Demande'),
      plural: t('Requests', 'Demandes'),
      description: t('What a colleague asks at your desk…', "Ce qu'un collègue demande à ton bureau…"),
      title: '/text',
      overline: '/department',
      idField: '/id',
      facets: ['derived:expected', '/redFlags', '/tricky', '/department'],
      derived: [{ id: 'expected', label: t('Right answer', 'Bonne réponse'),
        compute: (item) => expectedFor((item as AiRequest).redFlags).choice,
        options: { ai: { label: t('AI', 'IA'), tone: 'success' }, 'not-ai': { label: t('NOT AI', 'PAS IA'), tone: 'danger' } } }],
      create: (config, id) => ({ id, department: '', text: '', character: config.content.characters[0]?.id ?? '', redFlags: [], tricky: false }),
    },
    // case, profile, word…
  },
  collections: [
    { id: 'requests', entity: 'request', items: ['/content/requests/*'] },
    { id: 'red-flags', entity: 'case', items: ['/content/redFlags/*', '/content/aiFit'], fixedReason: t('…', '…') },
  ],
  screens: [{ id: 'intro', name: t('Welcome', 'Accueil'), description: t('…', '…'), fields: ['/content/title', '/content/ui/start'] }],
  appearance: ['/content/characters', '/content/images', '/content/scenery'],
};

The studio reads a collection with collectionItems(config, collection) (concrete pointers and keys, in order), an element field with itemPointer(element, field) and the graph of a collection with referenceGraph(config, editor, collection) (nodes, edges, broken references). Without editor, it only offers the generated panel.

3. Replayable scoring

The server ignores the score sent by the client: it replays the events against the release config, in its source language. The scoring must therefore not depend on the language: answers carry identifiers (item, option, brick), never a displayed text. The game computes its local result with the translated config it received: with identifiers only, it is the same as the server's.

Write the grading rule of an item once, and use it both in the UI and in the scoring:

export function gradeItem(config: MyConfig, item: MyItem, answer: Json): ItemGrade { … }

function scoreFromEvents(config: MyConfig, events: readonly PlayEvent[]): GameResult {
  const items = new Map(config.content.items.map((item) => [item.id, item]));
  const grades = gradeAnswers(events, (id) => items.get(id), (item, a) => gradeItem(config, item, a));
  return buildResult(grades, maxScore(config), events);
}

The @dg/engine/core building blocks:

Block Use
gradeAnswers, buildResult first answer of each item, skills and duration
withoutClientClaims(events) the events as the server replays them (without session.completed or round.completed)
hintsBeforeAnswer(events), applyHintPenalty(points, used, { cost, available }) clues read before the answer, and their cost
AnswerKey, scriptedEvaluatorFor(answerKey), evaluateWithFallback answer key derived from the content, shared by the evaluator and the scoring
splitHighlight(text, passage) highlight a passage in a text (<mark>)
CyclingDeck(cards, rng) shuffled draw pile without repeats, reshuffled once empty (endless arcade game)
PROMPT_SLOTS, slotCapacity, promptBrickSchema(n), slotQuality, worstLevel instructions built from bricks (role, task, constraints, examples, format): brick schema and slot level (Prompt Quest, Double Agent)
SKILL_LABELS English and French skill labels

For answers written in the config (missions, combinations), derive an answer key from the content (AnswerKey<MyConfig>, a list of ScriptedItem): scriptedEvaluatorFor(key) becomes the createEvaluator of the definition, and the scoring grades with gradeScripted on the same key. In the game, evaluateWithFallback(host.evaluator, task, item) asks the host evaluator (scripted for the MVP, an LLM later) and falls back on the key if it fails.

4. The root component

The root component gets the host with injectGameHost<MyConfig>(). It must never call the API, read the router or read localStorage. The host provides:

Member Use
mode play, preview (testing a draft) or edit (editor, no timer)
config() live config, already in the language of the session: read its texts directly
locale() language of the session, a ContentLocale (config, engine texts); numbers follow the site language
t(text) a UiText written in the code (game title, SKILL_LABELS), never content
seed createRng(host.seed), never Math.random()
start(), pause(), resume(), complete(result), abandon(reason) game lifecycle
telemetry.emit(event), events game events, and their log to compute the result
clock.remainingMs(), clock.expired() timer (null without a timer)
hud.setScore(), hud.setCombo(), hud.setProgress(), hud.setLives() state of the common HUD (lives: arcade games, null by default); setProgress({ item, label }): label = label written by the game in the language of the play session (“Mission 2 of 6”), instead of “Round … · Item …”
hud.setShowUntimed(false) hides the “Mode: no timer” badge of a game that is never played against the clock (a running clock stays visible)
feedback(kind, message, points) sound and announcement of an answer (correct, wrong) or of information (info)
audio.play(id), audio.deny() manifest sounds; a refused action always calls deny()
assets.url(ref) URL of an AssetRef
reducedMotion() turn off non-essential animations

Typical flow: start(), then round.started, item.answered on every answer (with the raw answer in answer), round.completed, and finally host.complete(scoring.scoreFromEvents(host.config(), withoutClientClaims(host.events))). The result shown, duration included, is then exactly the one the server computes. When clock.expired() becomes true, end the game with the answers already given.

The @dg/engine/angular building blocks, to call in a component field:

Block Use
injectGameTimeout() one-shot timer (next item, conveyor belt) that freezes during the pause
injectGameCountdown(onExpire) time limit of a step (mission, question): start(s), remainingMs(), clear()
readGameKey(event) keys 1 to 9, Enter, arrows (direction) and letters A to Z (letter, word to type), outside input fields; Enter on a button stays with the native click
injectAutofocus() after a screen change, focus on the last [data-autofocus]
<dg-brand-logo /> logo of the fork's brand (branding.logo, named by branding.orgName), nothing without a logo; size through --dg-brand-logo-max-inline and -max-block

The page passes the config already rendered in the language of the session (translations applied by the server or the studio): a template reads host.config().content.intro, never a { en, fr }. To read a text by its pointer (editable zones, Phaser), use textAt(host.config(), pointer) from @dg/engine/core.

Put [dgEditable]="'/content/intro'" on every text bound to the config: in edit mode the element is outlined and a click selects the zone. On a text (or a <span> inside a button), the click selects without triggering the action; on the button itself, the click selects the label and keeps the action, so that the editor can walk through the game.

5. HUD, feedback, pause and end of game

The game draws no score, timer or pause menu: the runner shows them with the HUD components of @dg/ui (ui-hud-score, ui-hud-progress, ui-hud-timer, sound button, pause button), from host.hud, host.clock and host.status(). Escape pauses the game; the pause menu offers Resume, Start over, Sound and Quit.

Every host.feedback('correct' | 'wrong', message, points) is announced once by the runner's ui-answer-feedback card. If the game already shows its own correction, declare feedbackCard: false in its definition: the card is still read by screen readers but no longer visible. Do not put aria-live on what the game shows of the same feedback.

At the end of the game, the runner shows the end screen shared by every game (ui-end-screen): title, game summary, score, per-skill summary (labels from SKILL_LABELS, mastered at 80 %, in progress at 50 %), Play again and Quit. On the platform pages, the score shown is the one recalculated by the server, with the "Score verified by the server" note (and the rank in the organisation): it is the only score on screen.

The game has no end screen of its own. Once host.complete(...) is called, it shows nothing new (its scene may stay behind the end screen). It can only provide its own summary, shown under the end screen title: a component loaded lazily by loadEndSummary, which receives the result input (the result shown, the server's as soon as it is known) and the GameHost. The dg-end-summary building block of @dg/engine/angular lays it out: a title and figures specific to the game, with their pointer for in-place editing. Do not repeat the score or the skills.

// my-game.definition.ts
loadEndSummary: () => import('./my-summary').then((m) => m.MySummary),

// my-summary.ts
@Component({
  selector: 'dg-my-summary',
  imports: [EndSummary],
  template: `<dg-end-summary [heading]="ui().endTitle" headingPointer="/content/ui/endTitle"
    [stats]="stats()" />`,
})
export class MySummary {
  protected readonly host = injectGameHost<MyConfig>();
  readonly result = input.required<GameResult>();
  protected readonly ui = computed(() => this.host.config().content.ui);
  protected readonly stats = computed<EndSummaryStat[]>(() => {
    const { correct, total } = answerTally(this.result());
    return [{ label: this.ui().correctCount, value: `${correct} / ${total}`,
      pointer: '/content/ui/correctCount' }];
  });
}

The end screen also shows in the editor (without Quit), so the summary can be edited in place. Test the summary with renderGame(MySummary, { config, inputs: { result } }).

6. The Phaser scene

Phaser draws the scene, Angular the texts, HUD and controls. The canvas is hidden from screen readers: every useful text must also be in the DOM.

<dg-phaser-stage [scenes]="loadScenes" [context]="store" />
protected readonly loadScenes = () => import('./scenes/my-scene').then((m) => [m.MyScene]);

By default the scene fits whole in its frame (fit="contain"). A full-frame scenery passes fit="cover": the scene covers the frame, edges cropped, whatever its format (portrait phones included). ParallaxBackdrop (@dg/engine/phaser/runtime) mounts a multi-plane scenery: each plane follows the pointer at its own rate, the scenery drifts slowly, a new picture replaces the old one with a crossfade (scenery that changes over the rounds) and each plane is an editable zone on its pointer. Model: the office scene of AI Not AI (games/ai-not-ai/src/ui/scenes/office-scene.ts).

In full screen, the page gives the runner the whole screen height (fill input of dg-game-runner): its stage becomes a size container and exposes --dg-stage-block-size. A game that wants to fill the screen reads this variable for the height of its scene, with its page height as a fallback: block-size: var(--dg-stage-block-size, clamp(34rem, 80vh, 46rem)). A game that does not read it keeps its height. Mind wide formats then: a foreground sized on the width alone (a picture at 108 % of the width) can eat the scene in 16:9; cap its height (block-size: min(36cqi, 52%) + object-fit: cover, like the desk of AI Not AI).

Scenes extend BaseScene (@dg/engine/phaser/runtime), which provides this.host, this.context (the store shared with Angular), this.watch(source, react) to follow a signal, this.motion(ms) for animation durations and themeColor() to read the theme. EditableText and EditableImage bind a canvas text or image to the config and make it editable. Only import @dg/engine/phaser/runtime from lazy-loaded scene files: Phaser does not run under jsdom. The canvas is out of flow in dg-phaser-stage: it never sizes its parent and follows its frame on every layout change, even while paused. Give the frame its own size (for example aspect-ratio: 16 / 9).

7. Theme

The game only uses the --dg-game-* CSS variables. Its universe is defined in games/<id>/theme.css under [data-dg-game='<id>']; the fork overrides (brand colours, font, radius) are applied by the runner on the game container only. On a changed surface, --dg-game-text-muted (text softened as long as AA contrast holds) and --dg-game-border (20 % of the text over the surface) are recomputed.

8. Tests

Test the UI with the engine test bench, as in sample-game.spec.ts:

vi.useFakeTimers();
const config = defaultConfigIn(myGameCore, 'fr'); // the config as the page passes it, in French
const game = renderGame(MyGame, { config, mode: 'play', locale: 'fr', providers: [provideNoPhaser()] });
game.click('[data-action="start"]');
game.press('1');
await game.settle(); // promises (evaluator) and simulated time
game.wait(1000);
// game.host.sink.events, game.host.bus.played(), game.host.result, game.host.editorBridge.selected

Cover the three modes, refused actions (deny), the pause, the end of the timer, and check that host.result equals scoring.scoreFromEvents(config, withoutClientClaims(host.sink.events)). Scenes are covered by the end-to-end tests.

9. Content review

bun run content:review in the game package writes content-review.md: every text of the content in the source language, then in each translation (a text without an up-to-date translation is flagged ⚠ à traduire), with its pointer and the useful fields (id, skill, expected answer). A domain expert can then review the whole content in a few minutes; --check fails when the report is out of date. The script is one line: runContentReview(myGameCore, new URL('..', import.meta.url)).

10. Running the game

The platform runs a game with the runner, with the config already rendered in locale:

<dg-game-runner
  [definition]="definition" [config]="config" [mode]="'play'" [locale]="'fr'"
  [seed]="session.seed" [telemetry]="sink"
  (completed)="onCompleted($event)" (abandoned)="onAbandoned($event)"
  (restarted)="onRestarted()" />

In play and preview, sink is createHttpTelemetrySink(playSessionId, playToken): events are sent in batches every 5 seconds and at the end of the game, with retry after a failure. When the player plays again, the runner creates a new local session and emits restarted: the page then opens a new play session and passes its seed and sink.

11. Sounds

The runner plays the common engine sounds (ENGINE_SOUNDS: correct, wrong, deny, countdown, game start and end, ambient music). For game-specific sounds, generate them with tooling/sound into games/<id>/assets/audio/ and declare audioManifest: '<id>/audio' in the definition. Play them with host.audio.play(id) or host.audio.startMusic(id): the bus applies the manifest mix gain, the fork rules (rules.audio), mute (HUD sound button) and anti-repetition. A game's pack is declared in tooling/sound (game_pack('<id>', '<prefix>', CATALOG), prefixed ids, see its README); a tuned sound (whose pitch must not vary) carries tonal: true in the manifest.

A game can also replace an engine sound with its own: its sound declares replaces='<engine sound id>' in its SoundSpec (an id of ENGINE_SOUNDS, same category), and the manifest carries replaces. The game keeps calling host.feedback() and host.complete(): when the engine plays answer-correct, answer-wrong or session-end, the bus plays the game's sound instead, whatever the order in which the manifests load. Example: AI Not AI, whose ainotai-good, ainotai-bad and ainotai-over are the sounds of its original game.

Edit this page on GitHub (opens in a new tab)