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 select Git: 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 of src/games
such as
basic-tower-of-hanoi
, which would be
http://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.
junk-kit
.SUNRUSE
to your GitHub name).This means that the zipped build results will automatically be added as GitHub releases on every commit.
gem install travis
.travis encrypt your-personal-access-token --repo your-github-name/your-repository-name
.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.
Install
at (https://github.com/apps/renovate).All Repositories
or Only select repositories
and ensure that
junk-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.
Set Default Policy
,
Standard Bundle Distribution
is probably good enough.Quick Import
.GitHub
.Connect With Service
.junk-kit
.Import 1
.SUNRUSE
to your GitHub name).{game-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.
{file-path}
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 \
).
src/engine/src/{file-path}.ts
TypeScript which is included in every game.
src/engine/src/{file-path}.d.ts
Defines types which the engine expects games to define.
src/engine/src/index.pug
Rendered as index.html
in zipped games. The following variables are defined:
Name | Description |
---|---|
javascript |
The minified JavaScript generated for the game. |
src/games/{game-name}/src/{file-path}.ts
TypeScript included in the game.
src/games/{game-name}/src/{file-path}.svg
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
.
dist/{game-name}.zip
The built game artifact.
src/hot-reload/src/index.ts
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:
It is not good for:
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.
State
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
.
initial
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.
version
A number which identifies breaking changes to State
. If this does not match
that loaded from local storage, initial
will be used instead.
audioReady
Executed immediately after the Web Audio API is initialized, for the creation of virtual instruments.
function audioReady(): void {
// audioContext is available here.
}
beatsPerMinute
The number of beats per minute in the game's music.
renderBeat
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.
}
render
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
.
gameName
The name of the game from its path under src/games
, as a string.
state
The current state; modify as you please.
saveLoadAvailable
When truthy, mutation callbacks' save
, load
and drop
are likely to work.
When falsy, mutation callbacks' save
, load
and drop
will definitely not
work.
audioContext
The current Web Audio API context. This should only be used in the audioReady
and renderBeat
functions.
beat
The number of beats of game music rendered so far. This should only be used in
the renderBeat
function.
beatTime
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.
Truthiness
Either 1
or undefined
. Useful for indicating a true
/false
flag without
the overhead of return !1
or similar.
Json
Types which can be serialized to or deserialized from JSON.
linearInterpolate
Linearly interpolates between two values by a unit interval, extrapolating if that mix value leaves the 0...1 range.
dotProduct
console.log(dotProduct(3, 4, 5, 6)) // 39
Calculates the dot product of two vectors.
magnitudeSquared
console.log(magnitudeSquared(3, 4)) // 15
Calculates the square of the magnitude of a vector.
magnitude
console.log(magnitude(3, 4)) // 3.872983346
Calculates the magnitude of a vector.
distanceSquared
console.log(distanceSquared(8, 20, 5, 16)) // 15
Calculates the square of the distance between two vectors.
distance
console.log(distance(8, 20, 5, 16)) // 3.872983346
Calculates the distance between two vectors.
KeyCode
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.
elapse
// The time was 200.
elapse(650)
// The time is now 850.
Progress the timeline by the given number of milliseconds.
viewport
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".
viewportHorizontalAlignmentSignedUnitInterval
/viewportVerticalAlignmentSignedUnitInterval
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.
group
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.
sprite
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
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
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
.
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.
linear
// 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.
easeOut
// Configure the keyframe to interpolate from.
easeOut(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates quickly, decelerating towards the end.
easeIn
// Configure the keyframe to interpolate from.
easeIn(groupOrSprite)
// Elapse, then configure the keyframe to interpolate to.
Interpolates slowly, accelerating towards the end.
easeInOut
// 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.
ease
// 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
setOpacity(groupOrSprite, 0.4)
Sets the opacity, where 0 is fully transparent and 1 is fully opaque.
hide
hide(groupOrSprite)
Equivalent to setOpacity(groupOrSprite, 0)
.
show
show(groupOrSprite)
Equivalent to setOpacity(groupOrSprite, 1)
.
translateX
translateX(groupOrSprite, 20)
Translates by the given number of virtual pixels on the X axis.
translateY
translateY(groupOrSprite, 20)
Translates by the given number of virtual pixels on the Y axis.
translate
translate(groupOrSprite, 20, 65)
Translates by the given numbers of virtual pixels on the X and Y axes respectively.
rotate
rotate(groupOrSprite, 90)
Rotates by the given number of degrees clockwise.
scaleX
scaleX(groupOrSprite, 2)
Scales by the given factor on the X axis.
scaleY
scaleY(groupOrSprite, 2)
Scales by the given factor on the Y axis.
scale
scale(groupOrSprite, 2, 4)
Scales by the given factors on the X and Y axes respectively.
scaleUniform
scaleUniform(groupOrSprite, 2)
Scales by the given factor on the X and Y axes.
mapKey
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.
save
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)
load
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`)
drop
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
.