First, run npm i in both the client and server folder.
To start the game server, run node main.ts in the server folder. If it doesn't run, here's some troubleshooting:
- Try updating node to be able to run typescript files
- "Error [ERR_MODULE_NOT_FOUND]: Cannot find module": running typescript like this requires imports to have the
.tsextension. If the server imports a file, make sure to add the.tsextension. - "The requested module ... does not provide an export named ...": if you are importing types, you must use
import typeorimport {a, type b}
To start the game client, run npm run dev in the client folder.
The game client requires a token cookie to work. If the NEXT_PUBLIC_AUTH_ENABLED envvar is "true", then it will redirect to the b01lers auth page.
For dev, set NEXT_PUBLIC_AUTH_ENABLED to "false" and create a cookie "token" with any value.
To store games/view leaderboard, up the db by running docker compose up --build -d krauq-db in the deploy folder.
Admin page: visit /stat with the "key" cookie set to INTERNAL_KEY envvar.
Create an .env file like this in the root directory containing client + server folder
NEXT_PUBLIC_GAME_ENCODE_KEY="..." # key used to encrypt ws
NEXT_PUBLIC_GAME_SERVER="localhost:3002"
NEXT_PUBLIC_GAME_SECURE="false" // http vs https
NEXT_PUBLIC_SOLVE_THRESHOLD="10"
INTERNAL_KEY="..." # key used for admin endpoints
OBFUSCATE="true"
FLAG="bctf{test_flag}"
NEXT_PUBLIC_API_BASE="https://rctf-bctf.b01lers.net/api/v1"
NEXT_PUBLIC_AUTH_ENABLED="false" // enable to work with bctf
If you want to rig the games client side, edit generateNewGame in main.ts (change randGame).
A game consists of three parts: the generator, the engine, and the renderer.
The generator takes in a seed and generates any random elements needed for a game (for example the question given in the duolingo minigame). Make sure to use the provided RNG class to make the RNG predictable (otherwise the server will not be able to follow along with the client's inputs). These are located in client/gamelib/generators.
The engine takes in the generated params and does all the busy work (calculation and stuff). Every frame, it takes in a list of inputs (mouse/keyboard)
and manipulates game state. It can also emit events for the renderer to display stuff. When a player wins or loses the game, please set the winState variable to "win" or "lose"!. These are located in client/gamelib/engines.
Finally, the renderer takes in the engine and renders it to the screen using PIXI.js. These are located in client/renderers.
To register another game, do the following:
- Add the game entry to
client/gamelib/games.ts.
<game_id>: {
generate: <your generator>,
engine: <your engine>,
frameLimit: <number of frames the player has>,
allowedActions: <list of allowed events>,
},
- Add the renderer to
client/renderers/Renderers.ts
<game_id>: {
renderer: <your renderer>,
assets: [<list of assets that your renderer uses>],
caption: <caption that is displayed before the game>,
playEndCorrect: <whether to play a random correct sound when the game ends>,
playEndIncorrect: <whether to play a random incorrect sound when the game ends>,
},
A generator takes in a seed (a number) and generates a state. Please use the RNG class for anything random!!!.
An engine must extend the BaseGame class. Put your game id in generic field so that the options variable has the right type.
Here's an example:
export class BashGame extends BaseGame<"bash"> {
constructor(options: GameOptions<"bash">, actions: GameAction[]) {
super(options, actions)
}
// called every frame
step() {
super.step()
const actionsThisFrame = this.getActionsThisFrame()
for (const action of actionsThisFrame) {
// handle each action
}
}
}Engines can emit events to that the renderer can listen to:
// engine
this.emit("buttonClicked", "data")
// renderer
engine.on("buttonClicked", (data: string) => {
console.log(data)
})I've provided a hitbox class that makes stuff easier. A hitbox is a rectangular area that can be interacted with. To create a hitbox, use the following:
new Hitbox({
x: number
y: number
width: number
height: number
// allow dragging in the x direction
dragX: boolean
// allow dragging in the y direction
dragY: boolean
// if true, the hitbox will be considered hovering if it is being dragged, even if the mouse is not over it
hoverWhileDrag: boolean
// if true, the hitbox will still trigger events even if it is covered by another hitbox
senseThrough: boolean
// allow the hitbox to be "active" (takes text inputs after click)
allowActive: boolean
// whether the hitbox is currently enabled
enabled: boolean
})Every hitbox can subscribe to a list of events:
type Listeners = {
change: (() => void)[]
activestart: (() => void)[]
activeend: (() => void)[]
// x, y are mouse coords
drag: ((x: number, y: number) => void)[]
dragstart: (() => void)[]
dragend: (() => void)[]
hoverstart: (() => void)[]
hoverend: (() => void)[]
// left click: button 0, right click: button 2
mousedown: ((x: number, y: number, button: number) => void)[]
mouseup: (() => void)[]
mousemove: ((x: number, y: number) => void)[]
keydown: ((key: string) => void)[]
keyup: ((key: string) => void)[]
}
// example
hb.on("keydown", handleKeyDown)Each engine comes with a hitbox manager to process any events. When creating an engine, first add all your hitboxes to the hitbox manager:
constructor(options: GameOptions<"game">, actions: GameAction[]) {
super(options, actions)
const hitbox = new Hitbox({
x: 0,
y: 0,
width: 0,
height: 0,
})
hitbox.on("mousedown", () => {
console.log("hello")
})
this.hm.addHitbox(hitbox)
}Then, when handling actions, provide the event to the hitbox manager:
step() {
super.step()
const actionsThisFrame = this.getActionsThisFrame()
for (const action of actionsThisFrame) {
this.hm.process(action)
}
}A renderer takes in three parameters: the PIXI.js Application (mostly unused), a PIXI.js Container to place any game objects in, and the game engine.
export async function BashRenderer(
app: Application,
container: Container,
engine: BashGame,
) {The function should return a destroy callback that cleans up anything the game set up.
return () => {
// clean up anything here
}The center of the screen is located at (0,0). The dimensions are 1600x900 and can be used as GAME_WIDTH and GAME_HEIGHT.
To put assets into your game, place them in the client/public folder. Then, add the asset to the list of assets to load in client/renderers/renderers.ts.
To display an image: create a PIXI.js Sprite:
const sprite = Sprite.from("/game/image.png")
sprite.position.set(0, 0) // or position.x = 0, position.y = 0
sprite.anchor.set(0.5) // align center horizontally and vertically
sprite.tint = 0xff0000 // color the image red
sprite.alpha = 0.5 // half transparent
sprite.scale.set(2) // twice as largeTo draw shapes: create a PIXI.js Graphics object:
const box = new Graphics()
box
.roundRect(0, 0, 100, 100, 12)
.fill({ color: "#3a464e" })
.stroke({ color: "#3a464e", width: 2 })
box.pivot.set(100 / 2, 100 / 2) // can't use anchor :(To draw text: create a PIXI.js Text object (remember to actually import it since the default Text is the browser Text)
const text = new Text({
text: "test":,
style: {
fontFamily: "Chelsea Market",
fontSize: 60,
fill: "#ff8f8f",
fontWeight: "bold",
stroke: {
width: 10,
color: "#b91e1e",
join: "round",
},
},
})Finally, to add PIXI.js objects to the string, add them to the provided container:
container.addChild(sprite)
container.addChild(box)There's a convenience function createHitboxSprite that takes in a Hitbox and creates a transparent Sprite for
debugging.
To set a cursor on hover, do the following
sprite.interactive = true // allow events
sprite.cursor = "pointer"The howler.js library is installed to play sound. Example:
const sound = new Howl({
src: ["/bash/pipe.mp3"],
volume: 0.5, // please make stuff decently quiet
rate: 1.2 // play stuff faster if needed
})The animejs library is installed to do animations. Check out the docs for more. Example:
animate(sprite, {
x: [0, 100],
y: [0, 100],
alpha: 0.5,
duration: 200, // ms
ease: "inOut" // easing function
})Note that animating a PIXI object may cause errors when the sprite is destroyed (but we can ignore them for now).
Ideally, you won't need to loop every frame (since everything can be done via event listeners on the engine). If you need to, there's a Clock class to call a function every frame.
Remember to remove the game loop in the destroy callback.
const gameLoop = () => {
// stuff
}
gameLoop()
Clock.add(gameLoop)
return () => {
Clock.remove(gameLoop)
}If you want to force a certain game (good for development): edit client/renderers/_base/GameLoop.tsx:85
// temp code to force a game (client side)
const force: GameType | null = "sleep"If you want to disable the fake cloudflare screen,
comment out this in client/renderers/_base/CanvasApplication.tsx:139:
<CFOverlay onClick={() => startGame()} />then, add startGame() to here:
active: async () => {
await app.init({
canvas: canvas,
width: GAME_WIDTH,
height: GAME_HEIGHT,
resolution: window.devicePixelRatio || 1,
backgroundColor: 0x0a0a0a,
antialias: true,
preference: "webgl",
powerPreference: "low-power",
})
destroy = () => {
app.destroy(true, {
children: true,
texture: true,
})
}
appRef.current = app
onResize()
startGame() // add this
},