This repository is intended to be a "one-stop shop" for building a subset of types of Js13kGames entries. It features:
-
A "watch" pipeline including minification, zipping and size checking for realtime feedback on how your changes affect artifact size.
-
Game-specific and shared codebases combined during the build pipeline.
-
Code generation from content, for better minification and build-time type checks.
-
Hot reload.
-
Continuous integration.
See an example game!
-
If you don't have a GitHub account, sign up for free.
-
Fork this repository. This makes your own copy which you can edit to your heart's content. To do this, click
Fork
in the top right corner of this repository's page on GitHub. -
Change all references to
SUNRUSE/junk-kit
(links in this file,package.json
, license, etc.) to point to your fork; noting that some of these will be URL-encoded, i.e.SUNRUSE%2Fjunk-kit
.
-
Install Git.
-
Install Visual Studio Code.
-
Install Node.js. I'd recommend LTS.
-
Clone this repository.
You can do this by opening Visual Studio Code, pressing F1, then entering
clone
and pressing enter to selectGit: Clone
.You will then be prompted for the URL of your forked repository, then, a place to clone it into. Once it is done, a blue
Open Repository
button will appear in the bottom right. Click on it.
-
Open Visual Studio Code if it is not open.
-
If something other than your project is open, click
File
,Open Folder
and select the folder you cloned your fork to. -
Press Ctrl+Shift+B and you should see a command-line application start in the terminal at the bottom of Visual Studio Code.
-
Your games should now be testable at
http://localhost:3333/{game-name}
, where{game-name}
is the name of a sub-folder ofsrc/games
such asbasic-tower-of-hanoi
, which would behttp://localhost:3333/basic-tower-of-hanoi
. -
Any changes you make, to code or content, will be reflected there automatically.
See File structure
for details on adding new or modifying existing games.
It is highly recommended to set up the following continuous integration services.
This means that your games will be built for you whenever you push changes to your fork, and the zipped games uploaded as GitHub releases.
- Sign into Travis CI with GitHub.
- Click on the slide toggle next to
junk-kit
. - Update all Travis CI links in this file to point to your fork (change
SUNRUSE
to your GitHub name).
This means that the zipped build results will automatically be added as GitHub releases on every commit.
- Generate a GitHub personal access token.
- Install Ruby.
- In the terminal, type
gem install travis
. - In the terminal, type
travis encrypt your-personal-access-token --repo your-github-name/your-repository-name
. - Replace the existing
secure: "encrypted-personal-access-token"
in .travis.yml with that written to the terminal.
The following continuous integration services may be useful for forks of the build pipeline, but are less useful for making your own games.
This means that any updates to the tools used to build games will be presented to you as GitHub pull requests in your fork.
- Click
Install
at (https://github.com/apps/renovate). - Either select
All Repositories
orOnly select repositories
and ensure thatjunk-kit
is selected.
This means that your fork's dependencies will be checked to ensure that their licenses do not conflict or present unexpected obligations.
- Sign up with GitHub at FOSSA.
- Follow the profile setup steps. For
Set Default Policy
,Standard Bundle Distribution
is probably good enough. - Choose
Quick Import
. - Choose
GitHub
. - Click
Connect With Service
. - Tick
junk-kit
. - Click
Import 1
. - Update all FOSSA links in this file to point to your fork (change
SUNRUSE
to your GitHub name).
Up to 50 characters, where:
-
The first character is a lower case letter.
-
The last character is a lower case letter or digit
-
Every other character is a lower case letter, digit or hypen.
Any file path (including files within folders), where:
-
The first character is a lower case letter.
-
The last character is a lower case letter or digit
-
Every other character is a lower case letter, digit or hyphen.
-
Hyphens are forbidden immediately preceding or following a folder separator (
/
or\
).
TypeScript which is included in every game.
Defines types which the engine expects games to define.
Rendered as index.html
in zipped games. The following variables are defined:
Name | Description |
---|---|
javascript |
The minified JavaScript generated for the game. |
TypeScript included in the game.
SVG minified and included in the game's TypeScript global scope. For instance,
src/games/test-game-name/src/complex-multi-level/folder-structure/with-a-file.svg
will be available in the game's TypeScript global scope as
complexMultiLevel_folderStructure_withAFile_svg
.
The built game artifact.
TypeScript which is included in every game during debug builds to enable hot reload.
The included game engine is a little unconventional, and may not be appropriate for your own games.
It is optimised for:
- Small artifact size.
- Hot reload.
- Resolution independence.
- Low system load during inactivity.
- Mouse or touch input.
It is not good for:
- Complex animation.
- Physics.
- Keyboard or gamepad input.
initial -.-> state -> render -> viewports -.-> groups/sprites
| '-> hitboxes --.
'------------------------------------------------'
All mutable game state is stored in a single JSON-serializable object called
state
. This is loaded from local storage if available, with fallback to an
initial state.
The build system does not make use of any kind of bundling or closures to keep your game and engine code separate. This is to give the minification process the best chance at creating the smallest build artifacts.
For that reason, avoid referencing or defining anything prefixed engine
or
Engine
on the global scope within game code. This is likely an internal
implementation detail which could break in future engine updates.
The following must be defined by your game TypeScript for building to succeed.
A JSON-serializable type which contains all mutable state for your game.
If breaking changes are made to this (such as changing the JSON which would be
de/serialized in such a way that state recovered from local storage would no
longer work) please change version
.
A function which returns a new instance of the default state, used when local storage does not contain a state, or the state is not usable.
A number which identifies breaking changes to State
. If this does not match
that loaded from local storage, initial
will be used instead.
Executed immediately after the Web Audio API is initialized, for the creation of virtual instruments.
function audioReady(): void {
// audioContext is available here.
}
The number of beats per minute in the game's music.
Called once per beat while the music is playing. Use this to generate the game's music, one beat at a time.
function renderBeat(): void {
// audioContext, beat and beatTime are available here.
}
function render(): undefined | (() => void) {
if (state.someCondition) {
// Use render emitters here.
// Any animations will play once.
return () => {
// Executed at the end of the animation; modify state here.
// Another render will then be performed.
// Will not be executed if a mapped key or hitbox is triggered.
}
} else {
// Use render emitters here.
// Any animations will loop until interrupted by a mapped key or hitbox.
return undefined
}
}
Executed when state
is known to have changed, to re-render the scene. See
Render Emitters for details on what can be done in this callback.
A mutation callback may be returned which is executed at the end of the animation.
A mutation callback is executed when an event occurs which could alter state,
and will be followed by a re-render
.
The name of the game from its path under src/games
, as a string.
The current state; modify as you please.
When truthy, mutation callbacks' save
, load
and drop
are likely to work.
When falsy, mutation callbacks' save
, load
and drop
will definitely not
work.
The current Web Audio API context. This should only be used in the audioReady
and renderBeat
functions.
The number of beats of game music rendered so far. This should only be used in
the renderBeat
function.
Converts a unit interval into the beat being rendered into a Web Audio API time.
const webAudioApiTimeOfBeatStart = beatTime(0)
const webAudioApiTimeOfBeatMidpoint = beatTime(0.5)
const webAudioApiTimeOfBeatEnd = beatTime(1)
This should only be used in the renderBeat
function.
Either 1
or undefined
. Useful for indicating a true
/false
flag without
the overhead of return !1
or similar.
Types which can be serialized to or deserialized from JSON.
Linearly interpolates between two values by a unit interval, extrapolating if that mix value leaves the 0...1 range.
console.log(dotProduct(3, 4, 5, 6)) // 39
Calculates the dot product of two vectors.
console.log(magnitudeSquared(3, 4)) // 15
Calculates the square of the magnitude of a vector.
console.log(magnitude(3, 4)) // 3.872983346
Calculates the magnitude of a vector.
console.log(distanceSquared(8, 20, 5, 16)) // 15
Calculates the square of the distance between two vectors.
console.log(distance(8, 20, 5, 16)) // 3.872983346
Calculates the distance between two vectors.
A type which represents a HTML5 key code. This maps to a location on the keyboard, not what the key is mapped to.
These can be called during the render callback to describe something which the render emits.
// The time was 200.
elapse(650)
// The time is now 850.
Progress the timeline by the given number of milliseconds.
const createdViewport = viewport(
320, // viewportMinimumWidthVirtualPixels
240, // viewportMinimumHeightVirtualPixels
420, // viewportMaximumWidthVirtualPixels
400, // viewportMaximumHeightVirtualPixels
0, // viewportHorizontalAlignmentSignedUnitInterval
0, // viewportVerticalAlignmentSignedUnitInterval
)
Viewports sit directly under the root of the scene graph. They persist until
the next render
. They cannot be animated.
viewportMinimumWidthVirtualPixels
/viewportMinimumHeightVirtualPixels
/viewportMaximumWidthVirtualPixels
/viewportMaximumHeightVirtualPixels
The X axis runs from center to right, while the Y axis runs from center to bottom.
A "virtual resolution" is specified, which maps to SVG pixels. The minimum
width
and height
define the "safe area" which is guaranteed to be visible.
This will be made as large as possible without cropping it or distorting the
aspect ratio.
The maximum
width
and height
define how much margin is visible around the
"safe area" when the display resolution's aspect ratio does not match that of
the "safe area".
For instance, in the above example, if the screen is wider than a 4:3 aspect ratio, up to 50 extra virtual pixels will be shown left of X -160, and a further 50 right of X 160. The viewport will be cropped beyond the "maximum".
Viewports are alignable to display borders, for elements such as buttons which should be near the edges of devices.
Horizontal and vertical alignment are signed unit intervals, where -1 aligns the left and top borders of the viewport with those of the display, 0 centers the viewport on the display, and 1 aligns the right and bottom borders of the viewport with those of the display.
const createdGroup = group(parentViewportOrGroup)
Groups are not themselves visible, but can be used to manipulate a set of other objects as a whole, or control render order. They are hidden until the time at which they were created.
const createdSprite = sprite(parentViewportOrGroup, importedFile_svg)
Sprites display imported SVG files. They are hidden until the time at which they were created.
Their origin is the center of the bounding box of the SVG.
hitbox(
parentViewport,
leftVirtualPixels,
topVirtualPixels,
widthVirtualPixels,
heightVirtualPixels,
() => {
state.aKeyPressed = true
}
)
Maps an area of the display to a mutation callback, which is then executed when that area is clicked on or touched. If multiple cover the same area, the last hitbox defined in the last viewport defined takes priority.
Hitboxes cannot be animated.
Their origin is their center.
sound(time => {
// audioContext is available here.
// "time" is the current elapsed time, in the Web Audio API's time space.
})
Executes a callback if the Web Audio API is available (and running). The time
elapse
d to, in Web Audio API time, is provided as an argument.
These describe how a subject object will interpolate between the current
keyframe and the next. The default behaviour is stepEnd
.
// Configure the group or sprite before the sudden transition.
stepEnd(groupOrSprite)
// Configure the group or sprite after the sudden transition.
Sets a hard transition; allows for changes without any interpolation.
// Configure the keyframe to interpolate from.
linear(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates linearly; at a constant rate. This makes the start and end of the motion somewhat abrupt.
// Configure the keyframe to interpolate from.
easeOut(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates quickly, decelerating towards the end.
// Configure the keyframe to interpolate from.
easeIn(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates slowly, accelerating towards the end.
// Configure the keyframe to interpolate from.
easeInOut(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates slowly, accelerates towards the middle, then decelerates again towards the end.
// Configure the keyframe to interpolate from.
ease(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates at moderate speed, accelerates towards the middle, then decelerates again towards the end.
These manipulate the current keyframe of the subject object. If the subject object has no keyframe at the current time, a new non-interpolating keyframe is created based on the previous keyframe first.
setOpacity(groupOrSprite, 0.4)
Sets the opacity, where 0 is fully transparent and 1 is fully opaque.
hide(groupOrSprite)
Equivalent to setOpacity(groupOrSprite, 0)
.
show(groupOrSprite)
Equivalent to setOpacity(groupOrSprite, 1)
.
translateX(groupOrSprite, 20)
Translates by the given number of virtual pixels on the X axis.
translateY(groupOrSprite, 20)
Translates by the given number of virtual pixels on the Y axis.
translate(groupOrSprite, 20, 65)
Translates by the given numbers of virtual pixels on the X and Y axes respectively.
rotate(groupOrSprite, 90)
Rotates by the given number of degrees clockwise.
scaleX(groupOrSprite, 2)
Scales by the given factor on the X axis.
scaleY(groupOrSprite, 2)
Scales by the given factor on the Y axis.
scale(groupOrSprite, 2, 4)
Scales by the given factors on the X and Y axes respectively.
scaleUniform(groupOrSprite, 2)
Scales by the given factor on the X and Y axes.
mapKey(`KeyA`, () => {
state.aKeyPressed = true
})
Maps a KeyCode
to a mutation callback. If multiple are defined with the same
KeyCode
, the last defined takes priority.
These are intended to be used only during a mutation callback.
Saves a JSON-serializable object under the given string key.
Returns truthy when successful.
Returns falsy and has no side effects when unsuccessful.
const truthyOnSuccess = save(`a-key`, aJsonSerializableValue)
Loads the JSON-serializable object with the given key. Makes no attempt to ensure that the deserialized object matches the specified type.
Returns the deserialized object when successful.
Returns null
when unsuccessful or not previously saved.
const deserializedOrNull = load<AJsonSerializableType>(`a-key`)
Deletes the object with the given string key.
Returns truthy when successful, including when no such object exists.
Returns falsy and has no side effects when unsuccessful.
const truthyOnNonFailure = drop(`a-key`)
The build pipeline is implemented using Node.JS and TypeScript.
There are two entry points: src/pipeline/cli.ts
and src/pipeline/ci.ts
, for
their respective usages. These should produce the same artifacts, but while
cli
is intended for local development purposes (watch builds, does not stop
on error, hosts build artifacts via HTTP with hot reload), ci
is instead
intended for continuous integration environments (stops on first error or
executed plan, logs more heavily).
files -> diff -> planning -> steps -> artifacts
| ^
v |
stores
-
A file source produces a list of file paths and corresponding version identifiers.
-
A diff algorithm determines which files have been added, deleted, modified and remain the same.
-
A planning algorithm generates a hierarchy of build steps need to be executed based on the diff.
-
The steps execute, caching to a set of stores.
-
Build artifacts are written to disk.
The most error-prone part of the build pipeline is planning; it can be difficult to determine exactly which steps should be executed based on the given diff.
To make it easier to determine exactly which steps were planned, it is possible to query the hierarchy for a nomnoml document detailing exactly which steps were planned to be executed and in what order.
To do this, call getNomNoml
on the result of plan
.