Skip to content

Unify game authoring around defineGame + composable systems with per-system scheduling #842

Description

@Noisemaker111

Problem

Building a game with JGengine currently requires too much engine-architecture work before the developer can focus on the game itself.

The project author has to understand and manually coordinate several separate concepts:

  • game.config.ts / defineGame
  • loop.ts lifecycle hooks
  • feature flags and subsystem registration
  • content, world, input, UI and presentation wiring
  • per-frame, per-player and scheduled work
  • save, reset, replication and cleanup behavior

This creates friction even though the engine already contains most of the necessary capabilities.

Current flaws

1. Game definition and runtime behavior are split

A game's static resources are declared in one file while initialization, player joining, event handling and ticking live elsewhere.

To understand a game, a developer may need to jump between:

game.config.ts
loop.ts
world.ts
content.ts
setup.ts
main.tsx

The separation is based on JGengine internals rather than how game developers think about their game.

2. Capabilities are activated twice

Games often enable a capability with a feature flag and then manually register its content and event bindings.

For example, quest support may require both:

features: { quest: true }

and later:

ctx.game.quest.register(quests);
ctx.game.quest.bind("entity.died");

Supplying a quest system should be enough to install the quest capability and its infrastructure.

3. The central loop becomes a manual fan-out list

Large games manually call every ticking subsystem:

tickWorldBoss(ctx);
tickMobs(ctx, dt);
tickAuras(ctx);
tickHero(ctx, playerId, dt);
tickPets(ctx, playerId, dt);
tickDelve(ctx, playerId, dt);
tickMail(ctx, playerId);
tickAuction(ctx, playerId);

This makes the game entry point know too much about every subsystem. It also makes timing policy repetitive and inconsistent across games.

4. Alternative abstractions create separate authoring paths

Cartridges reduce wiring for games that match their shape, but become a second public game-definition model. Games that outgrow the abstraction must return to raw defineGame and hand wiring.

That creates a rewrite cliff:

high-level cartridge
        ↓ game becomes unusual
raw defineGame + manual wiring

There should be one game-authoring path.

5. A giant universal config would lock games into another schema

Replacing the current API with an increasingly large game.config.ts schema would only move the problem. Every unusual game would require new fields, escape hatches or another alternative path.

6. Hidden feature registration is also undesirable

Moving everything into setupWorld(game), setupCombat(game), etc. creates a wall of setup calls and hides lifecycle ordering.

Likewise, scattering invisible update subscriptions across arbitrary files can make runtime order difficult to reason about.

Proposed direction

Keep one public game-authoring model: defineGame.

export const game = defineGame({
  name: "My Game",

  world,

  player: player({
    health: 100,
    controller: thirdPersonController(),
  }),

  systems: [
    runFlow(),
    enemyDirector(),
    combat(),
    progression(),
  ],

  ui: standardHud(),
});

The definition should be the clear, readable composition root for the game.

Systems should represent meaningful game capabilities—not tiny tick functions.

Good:

systems: [
  combat(),
  quests(),
  crafting(),
  dropInCoop(),
]

Too granular:

systems: [
  projectileTick(),
  cooldownTick(),
  auraTick(),
  damageTick(),
]

The smaller operations remain internal to combat().

Per-system timing

Each system should declare how it runs. The engine compiles all system schedules into the actual game loop.

Fixed simulation

export const combat = defineSystem({
  id: "combat",

  tick: {
    type: "fixed",
    rate: 60,
    stage: "combat",
  },

  update(ctx, dt) {
    updateProjectiles(ctx, dt);
    updateDamage(ctx, dt);
    resolveDeaths(ctx);
  },
});

Interval work

export const enemyDirector = defineSystem({
  id: "enemy-director",

  tick: {
    type: "interval",
    every: 1,
  },

  update(ctx) {
    updateEncounterPressure(ctx);
    spawnEnemies(ctx);
  },
});

Frame/presentation work

export const presentation = defineSystem({
  id: "presentation",

  tick: {
    type: "frame",
  },

  update(ctx, frame) {
    updateCamera(ctx, frame);
    updateEffects(ctx, frame);
  },
});

Event-only systems

export const progression = defineSystem({
  id: "progression",

  events: {
    "enemy.defeated"(ctx, event) {
      grantXp(ctx, event.playerId, event.xp);
    },

    "quest.completed"(ctx, event) {
      grantRewards(ctx, event);
    },
  },
});

A possible timing contract:

type SystemTick =
  | { type: "fixed"; rate?: number; stage?: string }
  | { type: "frame"; stage?: string }
  | { type: "interval"; every: number }
  | { type: "manual" };

Ordering

Runtime order must remain deterministic.

The game or engine can define broad stages:

fixed:
  input
  movement
  combat
  ai
  activities
  cleanup

frame:
  animation
  camera
  effects

Systems select a stage and may optionally declare local ordering constraints:

combat({
  tick: {
    type: "fixed",
    stage: "combat",
    after: "movement",
    before: "death-resolution",
  },
});

Most systems should only need a stage. Explicit before/after constraints should be reserved for real dependencies.

System ownership

A system should own its complete engine contract where applicable:

defineSystem({
  id: "quests",

  create(ctx) {},
  start(ctx) {},
  update(ctx, dt) {},
  events: {},

  save: {},
  replicate: {},
  reset(ctx) {},
  dispose(ctx) {},
});

This prevents capabilities from being added to gameplay while accidentally being omitted from saving, replication, reset or cleanup.

Player configuration

player(...) should remain a first-class part of the game definition because the player is a major game concept, but it should avoid excessive nested constructors and engine jargon.

The API should support concise common cases while allowing custom implementations:

player: player({
  health: 100,
  controller: thirdPersonController(),
})

A custom game can replace or extend any part without changing authoring models.

Non-goals

  • Do not create cartridges or genre-specific game-definition formats.
  • Do not replace defineGame with a universal giant schema for every possible genre.
  • Do not require a separate main.tsx, game.ts and loop.ts puzzle just to launch a game.
  • Do not expose every low-level tick function in the composition root.
  • Do not make runtime ordering accidental or dependent on import order.
  • Do not require feature flags when installing a system already proves the capability is used.

Desired outcome

A developer should be able to open one definition and understand the important shape of the game:

export const game = defineGame({
  name: "World of ClaudeCraft",
  world,
  player,
  systems: [
    combat(),
    mobs(),
    quests(),
    professions(),
    economy(),
    activities(),
    social(),
  ],
  ui,
});

The engine should then:

  • build the runtime context
  • install each capability once
  • validate dependencies and conflicts
  • compile fixed/frame/interval/event execution
  • preserve deterministic ordering
  • run per-player work where declared
  • include system-owned save, reset, replication and cleanup behavior

The developer should focus on game behavior and system composition rather than designing the engine assembly process for every project.

Acceptance criteria

  • defineGame is the single public game-authoring path.
  • Systems can declare fixed, frame, interval, event-only or manual execution.
  • Multiple systems may subscribe to the same timing channel.
  • Fixed/frame systems have deterministic stage ordering.
  • Systems can declare optional dependency and ordering constraints.
  • Installing a system activates its capability without a redundant feature flag.
  • Systems can own save, replication, reset and disposal integration.
  • The composition root lists meaningful capabilities rather than individual tick functions.
  • Existing classic games can migrate incrementally.
  • Cartridge functionality is expressible as ordinary reusable systems and can eventually stop being a separate public authoring model.
  • A representative complex game such as Claudecraft can express its runtime without a giant manual tick fan-out in the game definition.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions