Setup (requires node.js):
> npm installStart tests:
> npm testServe up the App (and ctrl-click the URL that appears in the console)
> npm run devTo format your code, for the assignment specifications:
npx prettier . --writeThe 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.
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
idfields
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
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.
The following are implemented on top of the Full Game requirements, aimed at the High HD range:
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.
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.
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.
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.