Skip to content

About

A falling-target matching game built with TypeScript and RxJS, exploring functional reactive programming — composed event streams, scan-based state reduction, and switchMap-driven restart logic.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Setup (requires node.js):

> npm install

Start tests:

> npm test

Serve up the App (and ctrl-click the URL that appears in the console)

> npm run dev

To format your code, for the assignment specifications:

npx prettier . --write

The configuration for this is set in .prettierrc.json. Feel free to change this to your heart's desire, but try to ensure it still fits the assignment guidelines.

If you are using VS Code, you can also install the Prettier extension. This skeleton code is set up to automatically format your code on save. You can disable this in .vscode/settings.json by changing "editor.formatOnSave": true to "editor.formatOnSave": false.

Implementing features

There are a few files you may wish to modify. The rest should not be modified as they are used for configuring the build.

src/main.ts

  • Code file used as the entry point
  • Most of your game logic should go here
  • Contains main function that is called on page load

src/style.css

  • Stylesheet
  • You may edit this if you wish

index.html

  • Main html file
  • Contains scaffold of game window and some sample shapes
  • Feel free to add to this, but avoid changing the existing code, especially the id fields

test/*.test.ts

  • If you want to add tests, these go here
  • Uses vitest

We expect the core logic of your game to be in src/main.ts, however, you may elect to spread your code over multiple files. In this case, please use TS Modules.

Avoid separating code into too many files as it makes it hard to mark. The maximum recommended code file structure would be something like

src/
  main.ts        -- main code logic inc. core game loop
  types.ts       -- common types and type aliases
  util.ts        -- util functions
  state.ts       -- state processing and transformation
  view.ts        -- rendering
  observable.ts  -- functions to create Observable streams

Game Design

Flippy Bit follows the four-ingredient FRP skeleton from the course's Asteroids notes: a constant-rate tick$ merged with user-input streams, scanned over a single immutable State, subscribed to a pure render function. Every stream in main.ts emits a Reducer ((s: State) => State) rather than acting directly — the actual state transition only ever happens once, inside scan.

Core gameplay: targets fall from the top of the canvas toward a check line, each showing its value in hexadecimal. The player holds an 8-digit binary answer, controlled by keyboard (arrow keys / A / D to move the cursor, Space to flip, R to restart) and mouse (hover to move the cursor, click to flip). Per the spec, the player's digit row is only ever compared against the lowest unresolved target — targets above it are ignored until it is resolved or lost. A successful match clears the digit row back to 00000000 so the next target can be attempted without manually resetting it.

Target spawning and target values are drawn from a seeded pure RNG (RNG.hash/RNG.scale, chained via lerp), rather than Math.random(), so that the spawn sequence within a round is a deterministic function of a starting seed — this keeps the spawn logic itself unit-testable. The one exception is the starting seed for each round, which is drawn from Math.random() inside createGame$ — a single, one-time impure call at the point a fresh round begins, so that repeated restarts don't replay an identical target sequence. Everything downstream of that one seed stays pure.

Additional Features (beyond the Full Game requirements)

The following are implemented on top of the Full Game requirements, aimed at the High HD range:

Pause

Listed in the spec as one of the recognised High HD options. Toggled with either the P key or the on-screen Pause button (pauseToggle$). While state.paused is true, tick short-circuits and returns state unchanged, so target movement, spawning, and the difficulty ramp all freeze — nothing about the round progresses while paused. A full-canvas overlay (matching the same pattern as the game-over screen) is shown while paused so it's unambiguous to the player, and is guaranteed never to be obscured by a falling target regardless of where targets are on screen.

Pausing also freezes player input, not just tick-driven progression: a whenActive higher-order wrapper is applied to every player-input stream (cursor movement, digit flipping by keyboard or mouse, hover tracking) via .pipe(map(whenActive)) at the point they're merged in createGame$. It turns each of their reducers into a no-op whenever state.paused, state.gameEnd, or state.countdownRemaining > 0, so a paused game is genuinely inert — the player can't move the cursor or pre-load bit flips while paused (or during the restart countdown) and then have them apply the instant the game resumes. tick$ and pauseToggle$ are deliberately left unwrapped: tick must keep running to freeze itself and decrement the countdown, and pauseToggle$ must stay live or pause could never be undone.

Restart countdown (not a spec requirement)

A 3-2-1 countdown (countdownRemaining, Constants.RESTART_COUNTDOWN_MS) runs at the start of every round — both the very first page load and every subsequent restart — during which tick freezes gameplay identically to pause, and player input is frozen the same way (see whenActive above). This is not required anywhere in the assignment spec; it was added as a deliberate polish feature so a restart doesn't drop the player straight back into falling targets with no chance to get oriented, similar to the "ready" beat in many arcade-style games. It reuses the same full-canvas overlay pattern as the game-over and pause screens.

Difficulty ramp with a floor on how fast it can accelerate

The spec requires only that "the game should speed up the longer the player survives," without specifying a mechanism. This implementation increases fall speed only (fallSpeedFor(speedLevel) — base speed plus a fixed increment per level), deliberately not the spawn-interval range, since the spawn timing (1–3 seconds between targets) is separately and explicitly fixed by the spec — narrowing it as difficulty increases would put the game in violation of that requirement.

The rate at which difficulty ramps up is itself bounded: the game speeds up faster and faster as the round progresses (the interval between fall-speed increases shrinks by SPEED_CHANGE_DECAY_MS each time one fires), but that shrinking interval is floored at MIN_SPEED_CHANGE_MS (2 seconds) — difficulty bumps can never come more often than every 2 seconds, however long the round runs. Without this bound, the ramp-of-a-ramp would shrink toward zero over a long round and the game would become unplayable rather than merely harder. This bound is what keeps the difficulty curve a deliberate, tunable design decision rather than an unbounded runaway effect.

Rendering optimised against non-tick state updates

state$ emits on every merged input stream, including mouse movement (hover$), not only on each game tick — so the render function is called far more often than the tick rate alone would suggest. To avoid tearing down and rebuilding every on-screen target's DOM nodes at mouse-movement frequency, targets are held in a persistent <g id="targetsLayer"> and patched by id (created once, then repositioned/relabelled in place; removed only once resolved or off-screen) rather than fully rebuilt every render call. The rest of the canvas (check line, digit row, score, overlays) is small and fixed-count, so it's still fully rebuilt each render — simpler, and cheap enough not to matter.

About

A falling-target matching game built with TypeScript and RxJS, exploring functional reactive programming — composed event streams, scan-based state reduction, and switchMap-driven restart logic.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages