Showing preview only (1,348K chars total). Download the full file or copy to clipboard to get everything.
Repository: scribble-rs/scribble.rs
Branch: master
Commit: 018e84862221
Files: 92
Total size: 1.2 MB
Directory structure:
gitextract_v5hmk19x/
├── .dockerignore
├── .gitattributes
├── .github/
│ └── workflows/
│ ├── docker-image-update.yml
│ ├── release.yml
│ ├── test-and-build.yml
│ └── test-pr.yml
├── .gitignore
├── .golangci.yml
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── LICENSE
├── README.md
├── cmd/
│ └── scribblers/
│ └── main.go
├── fly.Dockerfile
├── fly.toml
├── fly_deploy.sh
├── go.mod
├── go.sum
├── internal/
│ ├── api/
│ │ ├── createparse.go
│ │ ├── createparse_test.go
│ │ ├── doc.go
│ │ ├── http.go
│ │ ├── v1.go
│ │ └── ws.go
│ ├── config/
│ │ └── config.go
│ ├── frontend/
│ │ ├── doc.go
│ │ ├── http.go
│ │ ├── index.go
│ │ ├── index.js
│ │ ├── lobby.go
│ │ ├── lobby.js
│ │ ├── lobby_test.go
│ │ ├── resources/
│ │ │ ├── draw.js
│ │ │ ├── error.css
│ │ │ ├── index.css
│ │ │ ├── lobby.css
│ │ │ └── root.css
│ │ ├── templates/
│ │ │ ├── error.html
│ │ │ ├── favicon.html
│ │ │ ├── footer.html
│ │ │ ├── index.html
│ │ │ ├── lobby.html
│ │ │ └── non-static-css.html
│ │ └── templating_test.go
│ ├── game/
│ │ ├── data.go
│ │ ├── data_test.go
│ │ ├── lobby.go
│ │ ├── lobby_test.go
│ │ ├── shared.go
│ │ ├── words/
│ │ │ ├── ar
│ │ │ ├── de
│ │ │ ├── en_gb
│ │ │ ├── en_us
│ │ │ ├── fa
│ │ │ ├── fr
│ │ │ ├── he
│ │ │ ├── it
│ │ │ ├── nl
│ │ │ ├── pl
│ │ │ ├── ru
│ │ │ └── ua
│ │ ├── words.go
│ │ └── words_test.go
│ ├── metrics/
│ │ └── metrics.go
│ ├── sanitize/
│ │ └── sanitize.go
│ ├── state/
│ │ ├── doc.go
│ │ ├── lobbies.go
│ │ └── lobbies_test.go
│ ├── translations/
│ │ ├── ar.go
│ │ ├── de_DE.go
│ │ ├── doc.go
│ │ ├── en_us.go
│ │ ├── es_ES.go
│ │ ├── fa.go
│ │ ├── fr_FR.go
│ │ ├── he.go
│ │ ├── pl.go
│ │ ├── translations.go
│ │ └── translations_test.go
│ └── version/
│ └── version.go
├── linux.Dockerfile
├── tools/
│ ├── compare_en_words.sh
│ ├── sanitizer/
│ │ ├── README.md
│ │ └── main.go
│ ├── simulate/
│ │ └── main.go
│ ├── skribbliohintsconverter/
│ │ ├── README.md
│ │ ├── english.json
│ │ ├── german.json
│ │ └── main.go
│ ├── statcollector/
│ │ └── main.go
│ └── translate.sh
└── windows.Dockerfile
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# We are going with a whitelisting approach, to avoid accidentally bleeding
# too much into our container. This reduces the amount of cache miss and
# potentially speeds up the build.
*
# All non-test Go Code
!internal/
!pkg/
!cmd/
**_test.go
# Go Modules
!go.mod
!go.sum
# While the dockerfile aren't needed inside of the Dockerfile, fly.io requires
# them for remote bulding and uses this dockerignore to filter what's sent to
# the remote builder.
!linux.Dockerfile
!windows.Dockerfile
!public
================================================
FILE: .gitattributes
================================================
* text eol=lf
*.otf binary
*.png binary
*.wav binary
================================================
FILE: .github/workflows/docker-image-update.yml
================================================
name: docker image update
on:
workflow_run:
workflows: [Build]
branches: [v**]
types: [completed]
jobs:
main:
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 3
matrix:
os: [ubuntu-latest, windows-2022]
include:
- os: ubuntu-latest
platforms: linux/amd64,linux/arm/v7,linux/arm64
file: linux.Dockerfile
buildArgs: VERSION=${{ github.event.workflow_run.head_branch }}
tags: latest, ${{ github.event.workflow_run.head_branch }}
multiPlatform: true
- os: windows-2022
platforms: windows/amd64
file: windows.Dockerfile
buildArgs: VERSION=${{ github.event.workflow_run.head_branch }}
tags: windows-latest, windows-${{ github.event.workflow_run.head_branch }}
multiPlatform: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push
id: docker_build
uses: mr-smithers-excellent/docker-build-push@v6
with:
multiPlatform: ${{ matrix.multiPlatform }}
registry: docker.io
dockerfile: ${{ matrix.file }}
image: biosmarcel/scribble.rs
buildArgs: ${{ matrix.buildArgs }}
platform: ${{ matrix.platforms }}
tags: ${{ matrix.tags }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
================================================
FILE: .github/workflows/release.yml
================================================
name: Publish release
on:
workflow_run:
workflows: [Build]
branches: [v**]
types: [completed]
jobs:
publish-release:
runs-on: ubuntu-latest
# Kinda bad since it might release on any branch starting with v, but it'll do for now.
# Tag filtering in "on:" doesn't work, since the inital build trigger gets lost.
# github.ref is therefore also being reset to "refs/head/master".
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download linux artifact
uses: dawidd6/action-download-artifact@v6
with:
workflow: test-and-build.yml
name: scribblers-linux-x64
- name: Download macos artifact
uses: dawidd6/action-download-artifact@v6
with:
workflow: test-and-build.yml
name: scribblers-macos-x64
- name: Download windows artifact
uses: dawidd6/action-download-artifact@v6
with:
workflow: test-and-build.yml
name: scribblers-x64.exe
- name: Create release
uses: softprops/action-gh-release@v1
with:
name: ${{ github.event.workflow_run.head_branch }}
tag_name: ${{ github.event.workflow_run.head_branch }}
files: |
scribblers-linux-x64
scribblers-macos-x64
scribblers-x64.exe
================================================
FILE: .github/workflows/test-and-build.yml
================================================
name: Build
on: push
jobs:
test-and-build:
strategy:
matrix:
include:
- platform: windows-latest
binary_name: scribblers-x64.exe
- platform: ubuntu-latest
binary_name: scribblers-linux-x64
- platform: macos-latest
binary_name: scribblers-macos-x64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
# Workaround to get tags, getting git describe to work.
fetch-depth: 0
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.25.5
- name: Run tests
shell: bash
run: |
go test -v -race ./...
- name: Build artifact
shell: bash
env:
# Disable CGO to get "more static" binaries. They aren't really static ...
# since it can still happen that part of the stdlib access certain libs, but
# whatever, this will probs do for our usecase. (Can't quite remember
# anymore, but I have had issues with this in the past) :D
CGO_ENABLED: 0
run: |
go build -trimpath -ldflags "-w -s -X 'github.com/scribble-rs/scribble.rs/internal/version.Version=$(git describe --tags --dirty)'" -o ${{ matrix.binary_name }} ./cmd/scribblers
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.binary_name }}
path: ./${{ matrix.binary_name }}
================================================
FILE: .github/workflows/test-pr.yml
================================================
name: Run tests
on: pull_request
jobs:
run-tests:
strategy:
matrix:
go-version: [1.25.5]
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Run tests
run: |
go test -v -race -count=3 ./...
================================================
FILE: .gitignore
================================================
# Executable names
/scribblers
*.exe
__debug_bin
# IntelliJ state
.idea/
# Folder that contains code used for trying stuff locally
playground/
# Temporary output text files
*.out
# System metadata files
.DS_Store
# Configuration files
.env
# Anything temporary
*.tmp
# Profiling results
*.pprof
# Additional files for hosting. Workaround for now ig.
public/
================================================
FILE: .golangci.yml
================================================
linters:
enable-all: true
disable:
## These are deprecated
- exportloopref
## These are too strict for our taste
# Whitespace linter
- wsl
# Demands a newline before each return
- nlreturn
# Magic numbers
- mnd
# function, line and variable length
- funlen
- lll
- varnamelen
# testpackages must be named _test for reduced visibility to package
# details.
- testpackage
# I don't really care about cyclopmatic complexity
- cyclop
- gocognit
# I don't see the harm in returning an interface
- ireturn
# Too many false positives, due to easyjson rn
- recvcheck
# While aligned tags look nice, i can't be arsed doing it manually.
- tagalign
## Useful, but we won't use it for now, maybe later
# Allows us to define rules for dependencies
- depguard
# For some reason, imports aren't sorted right now.
- gci
# For now, we'll stick with our globals and inits. Everything needs to be
# rewrite to be more testable and safe to teardown and reset.
- gochecknoglobals
- gochecknoinits
# Seems to be very useful, but is also a very common usecase, so we'll
# ignore it for now
- exhaustruct
# Requires certain types of tags, such as json or mapstructure.
# While very useful, I don't care right now.
- musttag
# Not wrapping errors
- err113
- wrapcheck
# Code duplications
- dupl
## Provides no real value
- testifylint
# Broken
- goimports
linters-settings:
govet:
disable:
- fieldalignment
gocritic:
disabled-checks:
# This has false positives and provides little value.
- ifElseChain
gosec:
excludes:
# weak number generator stuff; mostly false positives, as we don't do
# security sensitive things anyway.
- G404
revive:
rules:
- name: var-naming
disabled: true
stylecheck:
checks: ["all", "-ST1003"]
run:
exclude-files:
- ".*_easyjson.go"
issues:
exclude-rules:
- path: translations\\[^e][^n].*?\.go
linters:
# Too many potential false positives
- misspell
# Exclude some linters from running on tests files. In tests, we often have
# code that is rather unsafe and only has one purpose, or furthermore things
# that indicate an issue in production, but are fine for testing only small
# units.
- path: _test\.go
linters:
- funlen
- cyclop
- forcetypeassert
- varnamelen
# The tools aren't part of the actual production code and therefore we don't
# care about codequality much right now.
- path: tools/
text: .+
================================================
FILE: .vscode/launch.json
================================================
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Start server",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/scribblers/main.go",
"cwd": "${workspaceFolder}"
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"html.format.templating": true,
"go.lintTool": "golangci-lint",
"go.useLanguageServer": true,
"gopls": {
"formatting.gofumpt": true,
},
}
================================================
FILE: LICENSE
================================================
BSD 3-Clause License
Copyright (c) 2019, scribble-rs
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: README.md
================================================
<h1 align="center">Scribble.rs</h1>
<p align="center">
<a href="https://discord.gg/cE5BKP2UnE"><img src="https://dcbadge.limes.pink/api/server/https://discord.gg/cE5BKP2UnE"></a>
<a href="https://ko-fi.com/N4N07DNY"><img src="https://ko-fi.com/img/githubbutton_sm.svg"></a>
</p>

Scribble.rs is a free and privacy respecting pictionary game. There's no
advertisements and you don't need an account to play.
It is an alternative to the web-based drawing game skribbl.io.
## Play now
There are some community hosted versions of the game (feel free to host your own instance and add it here!):
- [scribblers.bios-marcel.link](https://scribblers.bios-marcel.link) (Official instance, Note
that the instance may not respond instantly, as it automatically shuts down
if no traffic is received.)
- [scribble.bixilon.de](https://scribble.bixilon.de) (community instance maintained by @Bixilon)
- [scribble.drifty.win](https://scribble.drifty.win) (community instance maintained by @driftywinds for better latency in Asia)
## Join The Discord
Feel free to join the community Discord server to find people to play, talk, get
help or whatever else: https://discord.gg/cE5BKP2UnE
Note, the server is NOT very active.
## Donations
I haven't really accepted donations for a long time. But I think the project is
polished enough now to dare taking some money. Right now the hosting is very
minimal and there's no domain. The server is located in amsterdam and many
people playing seem to be from outside of europe. So it'd be nice to have a
decentralised deployment to provide a nice experience for everyone.
So donations would go towards infrastructure and a domain!
In the future I might add some fun little benefits for donators, I have no clear
vision of it yet though.
You can donate via Ko-Fi: https://ko-fi.com/biosmarcel
## Configuration
Configuration is read from environment variables or a `.env` file located in
the working directory.
Available settings:
| Key | Description | Default | Required |
| ----------------------------------------- | ---------------------------------------------------------------- | ------- | -------- |
| PORT | HTTP port that the server listens to. | 8080 | True |
| NETWORK_ADDRESS | TCP address that the server listens to. | | False |
| ROOT_PATH | Changes the path (after your domain) that the server listens to. | | False |
| CORS_ALLOWED_ORIGINS | | * | False |
| CORS_ALLOW_CREDENTIALS | | | False |
| LOBBY_CLEANUP_INTERVAL | | 90s | False |
| LOBBY_CLEANUP_PLAYER_INACTIVITY_THRESHOLD | | 75s | False |
For more up-to-date configuration, read the
[config.go](/internal/config/config.go) file.
## Docker
It is recommended that you run the server via Docker, as this will rule out
almost all compatibility issues.
Starting from v0.8.5, docker images are only built on tagged pushes. Each git
tag becomes a docker tag, however `latest` will always point to the latest
version released via GitHub.
### Linux Docker
Download the image:
```shell
docker pull biosmarcel/scribble.rs:latest
```
### Windows Docker
Only use this one if you want to run a native Windows container. Otherwise use
the Linux variant, as that's the default mode on Windows:
```shell
docker pull biosmarcel/scribble.rs:windows-latest
```
### Running the Docker container
Run the following, replacing `<port>` with the port you want the container to be
reachable from outside:
```shell
docker run --pull always --env PORT=8080 -p <port>:8080 biosmarcel/scribble.rs:latest
```
For example:
```shell
docker run --pull always --env PORT=8080 -p 80:8080 biosmarcel/scribble.rs:latest
```
Note that you can change `8080` too, but it is the internal port of the
container and you shouldn't have to change it under normal circumstances.
## Building / Running
Dependencies:
* [go](https://go.dev/doc/install) version 1.25.0 or later
* [git](https://git-scm.com/) (You can also download a .zip from Github)
In order to download and build, open a terminal and execute:
```shell
git clone https://github.com/scribble-rs/scribble.rs.git
cd scribble.rs
go build ./cmd/scribblers
```
This will produce a portable binary called `scribblers` or `scribblers.exe` if
you are on Windows.
## Pre-compiled binaries
In the [Releases](https://github.com/scribble-rs/scribble.rs/releases) section
you can find the latest stable release.
Alternatively each commit uploads artifacts which will be available for a
certain time.
**Note that these binaries might not necessarily be compatible with your
system. In this case, please use Docker or compile them yourself.**
## nginx
Since Scribble.rs uses WebSockets, when running it behind an nginx reverse
proxy, you have to configure nginx to support that. You will find an example
configuration on the [related Wiki page](https://github.com/scribble-rs/scribble.rs/wiki/reverse-proxy-(nginx)).
Other reverse proxies may require similar configuration. If you are using a
well known reverse proxy, you are free to contribute a configuration to the
wiki.
## Server-Side Metrics
While there's a Prometheus metrics endpoint at `/v1/metrics`, it currently
doesn't expose a lot of information. If there are any requests for certain data,
I'd be willing to extend it, as long as it doesn't expose any personal data.
While I do have a dashboard for it, my hoster (fly.io) sadly doesn't support
public dashboards right now, so the data will remain closed for now.
## Contributing
There are many ways you can contribute:
* Update / Add documentation in the wiki of the GitHub repository
* Create feature requests and bug reports
* Solve issues by creating Pull Requests
* If you are changing / adding behaviour, make an issue beforehand
* Tell your friends about the project
For contributions guidelines, see: https://github.com/scribble-rs/scribble.rs/wiki/Contributing
### Translations
For translations, please always use the `en_us.go` as your base. Other
translations might not be up-to-date. This will cause you to have missing keys
or translate obsolete keys.
## Credits
These resources are by people unrelated to the project, whilst not every of
these resources requires attribution as per license, we'll do it either way ;)
If you happen to find a mistake here, please make a PR. If you are one of the
authors and feel like we've wronged you, please reach out.
Some of these were slightly altered if the license allowed it.
Treat each of the files in this repository with the same license terms as the
original file.
* Logo - All rights reserved, excluded from BSD-3 licensing
* Background - All rights reserved, excluded from BSD-3 licensing
* Favicon - All rights reserved, excluded from BSD-3 licensing
* Rubber Icon - Made by [Pixel Buddha](https://www.flaticon.com/authors/pixel-buddha) from [flaticon.com](https://flaticon.com)
* Fill Bucket Icon - Made by [inipagistudio](https://www.flaticon.com/authors/inipagistudio) from [flaticon.com](https://flaticon.com)
* Kicking Icon - [Kicking Icon #309402](https://icon-library.net/icon/kicking-icon-4.html)
* Sound / No sound Icon - Made by Viktor Erikson (If this is you or you know who this is, send me a link to that persons Homepage)
* Profile Icon - Made by [kumakamu](https://www.iconfinder.com/kumakamu)
* [Help Icon](https://www.iconfinder.com/icons/211675/help_icon) - Made by Ionicons
* [Fullscreen Icon](https://www.iconfinder.com/icons/298714/screen_full_icon) - Made by Github
* [Pencil Icon](https://github.com/twitter/twemoji/blob/8e58ae4/svg/270f.svg)
* [Drawing Tablet Pen Icon](https://www.iconfinder.com/icons/8665767/pen_icon)
* [Checkmark Icon](https://commons.wikimedia.org/wiki/File:Green_check_icon_with_gradient.svg)
* [Fill Icon](https://commons.wikimedia.org/wiki/File:Circle-icons-paintcan.svg)
* [Trash Icon](https://www.iconfinder.com/icons/315225/trash_can_icon) - Made by [Yannick Lung](https://yannicklung.com)
* [Undo Icon](https://www.iconfinder.com/icons/308948/arrow_undo_icon) - Made by [Ivan Boyko](https://www.iconfinder.com/visualpharm)
* [Alarmclock Icon](https://www.iconfinder.com/icons/4280508/alarm_outlined_alert_clock_icon) - Made by [Kit of Parts](https://www.iconfinder.com/kitofparts)
* https://www.iconfinder.com/icons/808399/load_turn_turnaround_icon TODO
================================================
FILE: cmd/scribblers/main.go
================================================
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path"
"runtime/pprof"
"strings"
"syscall"
"time"
"github.com/go-chi/cors"
"github.com/scribble-rs/scribble.rs/internal/api"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/frontend"
"github.com/scribble-rs/scribble.rs/internal/state"
"github.com/scribble-rs/scribble.rs/internal/version"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalln("error loading configuration:", err)
}
log.Printf("Starting Scribble.rs version '%s'\n", version.Version)
if cfg.CPUProfilePath != "" {
log.Println("Starting CPU profiling ....")
cpuProfileFile, err := os.Create(cfg.CPUProfilePath)
if err != nil {
log.Fatal("error creating cpuprofile file:", err)
}
if err := pprof.StartCPUProfile(cpuProfileFile); err != nil {
log.Fatal("error starting cpu profiling:", err)
}
}
router := http.NewServeMux()
corsWrapper := cors.Handler(cors.Options{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowCredentials: cfg.CORS.AllowCredentials,
})
register := func(method, path string, handler http.HandlerFunc) {
// Each path needs to start with a slash anyway, so this is convenient.
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
log.Printf("Registering route: %s %s\n", method, path)
router.HandleFunc(fmt.Sprintf("%s %s", method, path), corsWrapper(handler).ServeHTTP)
}
// Healthcheck for deployments with monitoring if required.
register("GET", path.Join(cfg.RootPath, "health"), func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusOK)
})
api.NewHandler(cfg).SetupRoutes(cfg.RootPath, register)
frontendHandler, err := frontend.NewHandler(cfg)
if err != nil {
log.Fatal("error setting up frontend:", err)
}
frontendHandler.SetupRoutes(register)
if cfg.LobbyCleanup.Interval > 0 {
state.LaunchCleanupRoutine(cfg.LobbyCleanup)
}
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
defer os.Exit(0)
log.Printf("Received %s, gracefully shutting down.\n", <-signalChan)
state.ShutdownLobbiesGracefully()
if cfg.CPUProfilePath != "" {
pprof.StopCPUProfile()
log.Println("Finished CPU profiling.")
}
}()
address := fmt.Sprintf("%s:%d", cfg.NetworkAddress, cfg.Port)
log.Println("Started, listening on: http://" + address)
httpServer := &http.Server{
Addr: address,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path[len(r.URL.Path)-1] == '/' {
r.URL.Path = r.URL.Path[:len(r.URL.Path)-1]
}
router.ServeHTTP(w, r)
}),
ReadHeaderTimeout: 10 * time.Second,
}
log.Fatalln(httpServer.ListenAndServe())
}
================================================
FILE: fly.Dockerfile
================================================
#
# Builder for Golang
#
# We explicitly use a certain major version of go, to make sure we don't build
# with a newer verison than we are using for CI tests, as we don't directly
# test the produced binary but from source code directly.
FROM docker.io/golang:1.25.5 AS builder
WORKDIR /app
# This causes caching of the downloaded go modules and makes repeated local
# builds much faster. We must not copy the code first though, as a change in
# the code causes a redownload.
COPY go.mod go.sum ./
RUN go mod download -x
# Import that this comes after mod download, as it breaks caching.
ARG VERSION="dev"
# Copy actual codebase, since we only have the go.mod and go.sum so far.
COPY . /app/
ENV CGO_ENABLED=0
RUN go build -trimpath -ldflags "-w -s -X 'github.com/scribble-rs/scribble.rs/internal/version.Version=${VERSION}'" -tags timetzdata -o ./scribblers ./cmd/scribblers
#
# Runner
#
FROM scratch
# Additionally hosted files
COPY public /public
COPY --from=builder /app/scribblers /scribblers
# The scratch image doesn't contain any certificates, therefore we use
# the builders certificate, so that we can send HTTP requests.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/scribblers"]
# Random uid to avoid having root privileges. Linux doesn't care that there's no user for it.
USER 248:248
================================================
FILE: fly.toml
================================================
# See https://fly.io/docs/reference/configuration/
app = "scribblers"
# "fra" is only for paying customers
primary_region = "ams"
[metrics]
port = 8080
path = "/v1/metrics"
[build]
dockerfile = "fly.Dockerfile"
[deploy]
strategy = "immediate"
[env]
ROOT_URL = "https://scribblers.bios-marcel.link"
ALLOW_INDEXING = true
SERVE_DIRECTORIES = ":/public"
LOBBY_SETTING_BOUNDS_MAX_MAX_PLAYERS = 100
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
# Allows true scale to zero
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
timeout = "5s"
method = "GET"
path = "/health"
================================================
FILE: fly_deploy.sh
================================================
#!/bin/sh
flyctl deploy --build-arg "VERSION=$(git describe --tag)"
================================================
FILE: go.mod
================================================
module github.com/scribble-rs/scribble.rs
go 1.25.0
require (
github.com/Bios-Marcel/discordemojimap/v2 v2.0.6
github.com/Bios-Marcel/go-petname v0.0.1
github.com/caarlos0/env/v11 v11.4.0
github.com/go-chi/cors v1.2.2
github.com/gofrs/uuid/v5 v5.4.0
github.com/lxzan/gws v1.9.0
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/subosito/gotenv v1.6.0
golang.org/x/text v0.35.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/Bios-Marcel/discordemojimap/v2 v2.0.6 h1:VjNAT59riXBTKeKEqVb83irOZJ52a9qVy9HxzlL7C04=
github.com/Bios-Marcel/discordemojimap/v2 v2.0.6/go.mod h1:caQqGZkTnvXOLXjChOpjzXQUMy2C1Y61ImtdVzEOvss=
github.com/Bios-Marcel/go-petname v0.0.1 h1:FELp77IS2bulz77kFXUOHqRJHXoOlL0lJUf6no5S0cQ=
github.com/Bios-Marcel/go-petname v0.0.1/go.mod h1:67IdwdIEuQBRISkUQJd4b/DvOYscEo8dNpq0D2gPHoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lxzan/gws v1.9.0 h1:my3sfqb0GjwP+gRONjNWVzcBrpud0IP3vj9soIt9pXo=
github.com/lxzan/gws v1.9.0/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/api/createparse.go
================================================
package api
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/game"
"golang.org/x/text/cases"
)
// ParsePlayerName checks if the given value is a valid playername. Currently
// this only includes checkin whether the value is empty or only consists of
// whitespace character.
func ParsePlayerName(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return trimmed, errors.New("the player name must not be empty")
}
return trimmed, nil
}
// ParseLanguage checks whether the given value is part of the
// game.SupportedLanguages array. The input is trimmed and lowercased.
func ParseLanguage(value string) (*game.LanguageData, string, error) {
toLower := strings.ToLower(strings.TrimSpace(value))
for languageKey, data := range game.WordlistData {
if toLower == languageKey {
return &data, languageKey, nil
}
}
return nil, "", errors.New("the given language doesn't match any supported language")
}
func ParseScoreCalculation(value string) (game.ScoreCalculation, error) {
toLower := strings.ToLower(strings.TrimSpace(value))
switch toLower {
case "", "chill":
return game.ChillScoring, nil
case "competitive":
return game.CompetitiveScoring, nil
}
return nil, errors.New("the given score calculation doesn't match any supported algorithm")
}
// ParseDrawingTime checks whether the given value is an integer between
// the lower and upper bound of drawing time. All other invalid
// input, including empty strings, will return an error.
func ParseDrawingTime(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, cfg.LobbySettingBounds.MinDrawingTime,
cfg.LobbySettingBounds.MaxDrawingTime, "drawing time")
}
// ParseRounds checks whether the given value is an integer between
// the lower and upper bound of rounds played. All other invalid
// input, including empty strings, will return an error.
func ParseRounds(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, cfg.LobbySettingBounds.MinRounds,
cfg.LobbySettingBounds.MaxRounds, "rounds")
}
// ParseMaxPlayers checks whether the given value is an integer between
// the lower and upper bound of maximum players per lobby. All other invalid
// input, including empty strings, will return an error.
func ParseMaxPlayers(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, cfg.LobbySettingBounds.MinMaxPlayers,
cfg.LobbySettingBounds.MaxMaxPlayers, "max players amount")
}
// ParseCustomWords checks whether the given value is a string containing comma
// separated values (or a single word). Empty strings will return an empty
// (nil) array and no error. An error is only returned if there are empty words.
// For example these wouldn't parse:
//
// wordone,,wordtwo
// ,
// wordone,
func ParseCustomWords(lowercaser cases.Caser, value string) ([]string, error) {
trimmedValue := strings.TrimSpace(value)
if trimmedValue == "" {
return nil, nil
}
result := strings.Split(trimmedValue, ",")
for index, item := range result {
trimmedItem := lowercaser.String(strings.TrimSpace(item))
if trimmedItem == "" {
return nil, errors.New("custom words must not be empty")
}
result[index] = trimmedItem
}
return result, nil
}
// ParseClientsPerIPLimit checks whether the given value is an integer between
// the lower and upper bound of maximum clients per IP. All other invalid
// input, including empty strings, will return an error.
func ParseClientsPerIPLimit(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, cfg.LobbySettingBounds.MinClientsPerIPLimit,
cfg.LobbySettingBounds.MaxClientsPerIPLimit, "clients per IP limit")
}
// ParseCustomWordsPerTurn checks whether the given value is an integer between
// 0 and 100. All other invalid input, including empty strings, will return an
// error.
func ParseCustomWordsPerTurn(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, 1, cfg.LobbySettingBounds.MaxWordsPerTurn, "custom words per turn")
}
func ParseWordsPerTurn(cfg *config.Config, value string) (int, error) {
return parseIntValue(value, 1, cfg.LobbySettingBounds.MaxWordsPerTurn, "words per turn")
}
func newIntOutOfBounds(value, valueName string, lower, upper int) error {
if upper != -1 {
return fmt.Errorf("the value '%s' must be an integer between %d and %d, but was: '%s'", valueName, lower, upper, value)
}
return fmt.Errorf("the value '%s' must be an integer larger than %d, but was: '%s'", valueName, lower, value)
}
func parseIntValue(toParse string, lower, upper int, valueName string) (int, error) {
var value int
if parsed, err := strconv.ParseInt(toParse, 10, 64); err != nil {
return 0, newIntOutOfBounds(toParse, valueName, lower, upper)
} else {
value = int(parsed)
}
if value < lower || (upper > -1 && value > upper) {
return 0, newIntOutOfBounds(toParse, valueName, lower, upper)
}
return value, nil
}
// ParseBoolean checks whether the given value is either "true" or "false".
// The checks are case-insensitive. If an empty string is supplied, false
// is returned. All other invalid input will return an error.
func ParseBoolean(valueName, value string) (bool, error) {
if value == "" {
return false, nil
}
if strings.EqualFold(value, "true") {
return true, nil
}
if strings.EqualFold(value, "false") {
return false, nil
}
return false, fmt.Errorf("the %s value must be a boolean value ('true' or 'false)", valueName)
}
================================================
FILE: internal/api/createparse_test.go
================================================
package api
import (
"reflect"
"testing"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/game"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func Test_parsePlayerName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
wantErr bool
}{
{"empty name", "", "", true},
{"blank name", " ", "", true},
{"one letter name", "a", "a", false},
{"normal name", "Scribble", "Scribble", false},
{"name with space in the middle", "Hello World", "Hello World", false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParsePlayerName(testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parsePlayerName() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parsePlayerName() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseDrawingTime(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want int
wantErr bool
}{
{"empty value", "", 0, true},
{"space", " ", 0, true},
{"less than minimum", "59", 0, true},
{"more than maximum", "301", 0, true},
{"maximum", "300", 300, false},
{"minimum", "60", 60, false},
{"something valid", "150", 150, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseDrawingTime(&config.Default, testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseDrawingTime() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parseDrawingTime() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseRounds(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want int
wantErr bool
}{
{"empty value", "", 0, true},
{"space", " ", 0, true},
{"less than minimum", "0", 0, true},
{"more than maximum", "21", 0, true},
{"maximum", "20", 20, false},
{"minimum", "1", 1, false},
{"something valid", "15", 15, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseRounds(&config.Default, testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseRounds() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parseRounds() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseMaxPlayers(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want int
wantErr bool
}{
{"empty value", "", 0, true},
{"space", " ", 0, true},
{"less than minimum", "1", 0, true},
{"more than maximum", "25", 0, true},
{"maximum", "24", 24, false},
{"minimum", "2", 2, false},
{"something valid", "15", 15, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseMaxPlayers(&config.Default, testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseMaxPlayers() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parseMaxPlayers() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseCustomWords(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want []string
wantErr bool
}{
{"emtpty", "", nil, false},
{"spaces", " ", nil, false},
{"spaces with comma in middle", " , ", nil, true},
{"single word", "hello", []string{"hello"}, false},
{"single word upper to lower", "HELLO", []string{"hello"}, false},
{"single word with spaces around", " hello ", []string{"hello"}, false},
{"two words", "hello,world", []string{"hello", "world"}, false},
{"two words with spaces around", " hello , world ", []string{"hello", "world"}, false},
{"sentence and word", "What a great day, hello ", []string{"what a great day", "hello"}, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseCustomWords(cases.Lower(language.English), testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseCustomWords() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if !reflect.DeepEqual(got, testCase.want) {
t.Errorf("parseCustomWords() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseCustomWordsPerTurn(t *testing.T) {
t.Parallel()
cfg := &config.Config{
LobbySettingBounds: game.SettingBounds{
MinCustomWordsPerTurn: 1,
MinWordsPerTurn: 1,
MaxWordsPerTurn: 3,
},
}
tests := []struct {
name string
value string
want int
wantErr bool
}{
{"empty value", "", 0, true},
{"space", " ", 0, true},
{"less than minimum, zero", "0", 0, true},
{"less than minimum, negative", "-1", 0, true},
{"more than maximum", "4", 0, true},
{"minimum", "1", 1, false},
{"maximum", "3", 3, false},
{"something valid", "2", 2, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseCustomWordsPerTurn(cfg, testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseCustomWordsPerTurn() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parseCustomWordsPerTurn() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseWordsPerTurn(t *testing.T) {
t.Parallel()
cfg := &config.Config{
LobbySettingBounds: game.SettingBounds{
MinCustomWordsPerTurn: 1,
MinWordsPerTurn: 1,
MaxWordsPerTurn: 10,
},
}
tests := []struct {
name string
value string
want int
wantErr bool
}{
{"empty value", "", 0, true},
{"space", " ", 0, true},
{"less than minimum, zero", "0", 0, true},
{"less than minimum, negative", "-1", 0, true},
{"minimum", "1", 1, false},
{"something valid", "10", 10, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseWordsPerTurn(cfg, testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("ParseWordsPerTurn() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("ParseWordsPerTurn() = %v, want %v", got, testCase.want)
}
})
}
}
func Test_parseBoolean(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want bool
wantErr bool
}{
{"empty value", "", false, false},
{"space", " ", false, true},
{"garbage", "garbage", false, true},
{"true", "true", true, false},
{"true upper", "TRUE", true, false},
{"true mixed casing", "TruE", true, false},
{"false", "false", false, false},
{"false upper", "FALSE", false, false},
{"false mixed casing", "FalsE", false, false},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := ParseBoolean("name", testCase.value)
if (err != nil) != testCase.wantErr {
t.Errorf("parseBoolean() error = %v, wantErr %v", err, testCase.wantErr)
return
}
if got != testCase.want {
t.Errorf("parseBoolean() = %v, want %v", got, testCase.want)
}
})
}
}
================================================
FILE: internal/api/doc.go
================================================
// Package api the public APIs for both the HTTP and the WS endpoints.
// These are being used by the official client and can also be used by third
// party clients. On top of that this package contains some util code regarding
// http/ws that can be used by other packages. In order to register the
// endpoints you have to call SetupRoutes.
package api
================================================
FILE: internal/api/http.go
================================================
package api
import (
"net/http"
"path"
"strings"
"github.com/scribble-rs/scribble.rs/internal/metrics"
)
// SetupRoutes registers the /v1/ endpoints with the http package.
func (handler *V1Handler) SetupRoutes(rootPath string, register func(string, string, http.HandlerFunc)) {
v1 := path.Join(rootPath, "v1")
metrics.SetupRoute(func(metricsHandler http.HandlerFunc) {
register("GET", path.Join(v1, "metrics"), metricsHandler)
})
register("GET", path.Join(v1, "stats"), handler.getStats)
// These exist only for the public API. We version them in order to ensure
// backwards compatibility as far as possible.
register("GET", path.Join(v1, "lobby"), handler.getLobbies)
register("POST", path.Join(v1, "lobby"), handler.postLobby)
register("PATCH", path.Join(v1, "lobby", "{lobby_id}"), handler.patchLobby)
// We support both path parameter and cookie.
register("PATCH", path.Join(v1, "lobby"), handler.patchLobby)
// The websocket is shared between the public API and the official client
register("GET", path.Join(v1, "lobby", "{lobby_id}", "ws"), handler.websocketUpgrade)
// We support both path parameter and cookie.
register("GET", path.Join(v1, "lobby", "ws"), handler.websocketUpgrade)
register("POST", path.Join(v1, "lobby", "{lobby_id}", "player"), handler.postPlayer)
}
// remoteAddressToSimpleIP removes unnecessary clutter from the input,
// reducing it to a simple IPv4. We expect two different formats here.
// One being http.Request#RemoteAddr (127.0.0.1:12345) and the other
// being forward headers, which contain brackets, as there's no proper
// API, but just a string that needs to be parsed.
func remoteAddressToSimpleIP(input string) string {
address := input
lastIndexOfDoubleColon := strings.LastIndex(address, ":")
if lastIndexOfDoubleColon != -1 {
address = address[:lastIndexOfDoubleColon]
}
return strings.TrimSuffix(strings.TrimPrefix(address, "["), "]")
}
// GetIPAddressFromRequest extracts the clients IP address from the request.
// This function respects forwarding headers.
func GetIPAddressFromRequest(request *http.Request) string {
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
// The following logic has been implemented according to the spec, therefore please
// refer to the spec if you have a question.
forwardedAddress := request.Header.Get("X-Forwarded-For")
if forwardedAddress != "" {
// Since the field may contain multiple addresses separated by commas, we use the first
// one, which according to the docs is supposed to be the client address.
clientAddress := strings.TrimSpace(strings.Split(forwardedAddress, ",")[0])
return remoteAddressToSimpleIP(clientAddress)
}
standardForwardedHeader := request.Header.Get("Forwarded")
if standardForwardedHeader != "" {
targetPrefix := "for="
// Since forwarded can contain more than one field, we search for one specific field.
for part := range strings.SplitSeq(standardForwardedHeader, ";") {
trimmed := strings.TrimSpace(part)
if after, ok := strings.CutPrefix(trimmed, targetPrefix); ok {
// FIXME Maybe checking for a valid IP-Address would make sense here, not sure tho.
address := remoteAddressToSimpleIP(after)
// Since the documentation doesn't mention which quotes are used, I just remove all ;)
return strings.NewReplacer("`", "", "'", "", "\"", "", "[", "", "]", "").Replace(address)
}
}
}
return remoteAddressToSimpleIP(request.RemoteAddr)
}
================================================
FILE: internal/api/v1.go
================================================
// This file contains the API methods for the public API
package api
import (
json "encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gofrs/uuid/v5"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/game"
"github.com/scribble-rs/scribble.rs/internal/state"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var ErrLobbyNotExistent = errors.New("the requested lobby doesn't exist")
type V1Handler struct {
cfg *config.Config
}
func NewHandler(cfg *config.Config) *V1Handler {
return &V1Handler{
cfg: cfg,
}
}
func marshalToHTTPWriter(data any, writer http.ResponseWriter) (bool, error) {
bytes, err := json.Marshal(data)
if err != nil {
return false, err
}
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Length", strconv.Itoa(len(bytes)))
_, err = writer.Write(bytes)
return true, err
}
type LobbyEntries []*LobbyEntry
// LobbyEntry is an API object for representing a join-able public lobby.
type LobbyEntry struct {
LobbyID string `json:"lobbyId"`
Wordpack string `json:"wordpack"`
Scoring string `json:"scoring"`
State game.State `json:"state"`
PlayerCount int `json:"playerCount"`
MaxPlayers int `json:"maxPlayers"`
Round int `json:"round"`
Rounds int `json:"rounds"`
DrawingTime int `json:"drawingTime"`
MaxClientsPerIP int `json:"maxClientsPerIp"`
CustomWords bool `json:"customWords"`
}
func (handler *V1Handler) getLobbies(writer http.ResponseWriter, _ *http.Request) {
// REMARK: If paging is ever implemented, we might want to maintain order
// when deleting lobbies from state in the state package.
lobbies := state.GetPublicLobbies()
lobbyEntries := make(LobbyEntries, 0, len(lobbies))
for _, lobby := range lobbies {
// While one would expect locking the lobby here, it's not very
// important to get 100% consistent results here.
lobbyEntries = append(lobbyEntries, &LobbyEntry{
LobbyID: lobby.LobbyID,
PlayerCount: lobby.GetConnectedPlayerCount(),
MaxPlayers: lobby.MaxPlayers,
Round: lobby.Round,
Rounds: lobby.Rounds,
DrawingTime: lobby.DrawingTime,
CustomWords: len(lobby.CustomWords) > 0,
MaxClientsPerIP: lobby.ClientsPerIPLimit,
Wordpack: lobby.Wordpack,
State: lobby.State,
Scoring: lobby.ScoreCalculation.Identifier(),
})
}
if started, err := marshalToHTTPWriter(lobbyEntries, writer); err != nil {
if !started {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
}
func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Request) {
if err := request.ParseForm(); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
var requestErrors []string
// The lobby ID is normally autogenerated and it isn't advisble to set it
// yourself, but for certain integrations / automations this can be useful.
// However, to avoid any kind of abuse, this needs to be a valid UUID at
// least.
lobbyId := request.Form.Get("lobby_id")
if lobbyId != "" {
var err error
_, err = uuid.FromString(lobbyId)
if err != nil {
requestErrors = append(requestErrors, err.Error())
}
}
scoreCalculation, scoreCalculationInvalid := ParseScoreCalculation(request.Form.Get("score_calculation"))
languageRawValue := strings.ToLower(strings.TrimSpace(request.Form.Get("language")))
languageData, languageKey, languageInvalid := ParseLanguage(languageRawValue)
drawingTime, drawingTimeInvalid := ParseDrawingTime(handler.cfg, request.Form.Get("drawing_time"))
rounds, roundsInvalid := ParseRounds(handler.cfg, request.Form.Get("rounds"))
maxPlayers, maxPlayersInvalid := ParseMaxPlayers(handler.cfg, request.Form.Get("max_players"))
customWordsPerTurn, customWordsPerTurnInvalid := ParseCustomWordsPerTurn(handler.cfg, request.Form.Get("custom_words_per_turn"))
clientsPerIPLimit, clientsPerIPLimitInvalid := ParseClientsPerIPLimit(handler.cfg, request.Form.Get("clients_per_ip_limit"))
publicLobby, publicLobbyInvalid := ParseBoolean("public", request.Form.Get("public"))
wordsPerTurn, wordsPerTurnInvalid := ParseWordsPerTurn(handler.cfg, request.Form.Get("words_per_turn"))
if wordsPerTurn < customWordsPerTurn {
wordsPerTurnInvalid = errors.New("words per turn must be greater than or equal to custom words per turn")
}
var lowercaser cases.Caser
if languageInvalid != nil {
lowercaser = cases.Lower(language.English)
} else {
lowercaser = languageData.Lowercaser()
}
customWords, customWordsInvalid := ParseCustomWords(lowercaser, request.Form.Get("custom_words"))
if scoreCalculationInvalid != nil {
requestErrors = append(requestErrors, scoreCalculationInvalid.Error())
}
if languageInvalid != nil {
requestErrors = append(requestErrors, languageInvalid.Error())
}
if drawingTimeInvalid != nil {
requestErrors = append(requestErrors, drawingTimeInvalid.Error())
}
if roundsInvalid != nil {
requestErrors = append(requestErrors, roundsInvalid.Error())
}
if maxPlayersInvalid != nil {
requestErrors = append(requestErrors, maxPlayersInvalid.Error())
}
if customWordsInvalid != nil {
requestErrors = append(requestErrors, customWordsInvalid.Error())
}
if customWordsPerTurnInvalid != nil {
requestErrors = append(requestErrors, customWordsPerTurnInvalid.Error())
} else {
if languageRawValue == "custom" && len(customWords) == 0 {
requestErrors = append(requestErrors, "custom words must be provided when using custom language")
}
}
if clientsPerIPLimitInvalid != nil {
requestErrors = append(requestErrors, clientsPerIPLimitInvalid.Error())
}
if publicLobbyInvalid != nil {
requestErrors = append(requestErrors, publicLobbyInvalid.Error())
}
if wordsPerTurnInvalid != nil {
requestErrors = append(requestErrors, wordsPerTurnInvalid.Error())
}
if len(requestErrors) != 0 {
http.Error(writer, strings.Join(requestErrors, ";"), http.StatusBadRequest)
return
}
playerName := GetPlayername(request)
lobbySettings := &game.EditableLobbySettings{
Rounds: rounds,
DrawingTime: drawingTime,
MaxPlayers: maxPlayers,
CustomWordsPerTurn: customWordsPerTurn,
ClientsPerIPLimit: clientsPerIPLimit,
Public: publicLobby,
WordsPerTurn: wordsPerTurn,
}
player, lobby, err := game.CreateLobby(lobbyId, playerName,
languageKey, lobbySettings, customWords, scoreCalculation)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
// Due to the fact the IDs can be chosen manually, there's a big clash
// potential! However, since we only allow specifying this via the rest API
// we treat this here. This can't be treated in the game package right now
// anyway though, as there'd be an import cycle.
if state.GetLobby(lobby.LobbyID) != nil {
http.Error(writer, "lobby id already in use", http.StatusBadRequest)
return
}
lobby.WriteObject = WriteObject
lobby.WritePreparedMessage = WritePreparedMessage
player.SetLastKnownAddress(GetIPAddressFromRequest(request))
SetGameplayCookies(writer, request, player, lobby)
lobbyData := CreateLobbyData(handler.cfg, lobby)
if started, err := marshalToHTTPWriter(lobbyData, writer); err != nil {
if !started {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
// We only add the lobby if everything else was successful.
state.AddLobby(lobby)
}
func (handler *V1Handler) postPlayer(writer http.ResponseWriter, request *http.Request) {
lobby := state.GetLobby(request.PathValue("lobby_id"))
if lobby == nil {
http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound)
return
}
var lobbyData *LobbyData
lobby.Synchronized(func() {
player := GetPlayer(lobby, request)
if player == nil {
if !lobby.HasFreePlayerSlot() {
http.Error(writer, "lobby already full", http.StatusUnauthorized)
return
}
requestAddress := GetIPAddressFromRequest(request)
if !lobby.CanIPConnect(requestAddress) {
http.Error(writer, "maximum amount of players per IP reached", http.StatusUnauthorized)
return
}
newPlayer := lobby.JoinPlayer(GetPlayername(request))
newPlayer.SetLastKnownAddress(requestAddress)
// Use the players generated usersession and pass it as a cookie.
SetGameplayCookies(writer, request, newPlayer, lobby)
} else {
player.SetLastKnownAddress(GetIPAddressFromRequest(request))
}
lobbyData = CreateLobbyData(handler.cfg, lobby)
})
if lobbyData != nil {
if started, err := marshalToHTTPWriter(lobbyData, writer); err != nil {
if !started {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
}
}
func GetDiscordInstanceId(request *http.Request) string {
discordInstanceId := request.URL.Query().Get("instance_id")
if discordInstanceId == "" {
cookie, _ := request.Cookie("discord-instance-id")
if cookie != nil {
discordInstanceId = cookie.Value
}
}
return discordInstanceId
}
const discordDomain = "1320396325925163070.discordsays.com"
func SetDiscordCookies(w http.ResponseWriter, request *http.Request) {
discordInstanceId := GetDiscordInstanceId(request)
if discordInstanceId != "" {
http.SetCookie(w, &http.Cookie{
Name: "discord-instance-id",
Value: discordInstanceId,
Domain: discordDomain,
Path: "/",
SameSite: http.SameSiteNoneMode,
Partitioned: true,
Secure: true,
})
}
}
// SetGameplayCookies takes the players usersession and lobby id
// and sets them as a cookie.
func SetGameplayCookies(
w http.ResponseWriter,
request *http.Request,
player *game.Player,
lobby *game.Lobby,
) {
discordInstanceId := GetDiscordInstanceId(request)
if discordInstanceId != "" {
http.SetCookie(w, &http.Cookie{
Name: "usersession",
Value: player.GetUserSession().String(),
Domain: discordDomain,
Path: "/",
SameSite: http.SameSiteNoneMode,
Partitioned: true,
Secure: true,
})
http.SetCookie(w, &http.Cookie{
Name: "lobby-id",
Value: lobby.LobbyID,
Domain: discordDomain,
Path: "/",
SameSite: http.SameSiteNoneMode,
Partitioned: true,
Secure: true,
})
} else {
// For the discord case, we need both, as the discord specific cookies
// aren't available during the readirect from ssrCreate to ssrEnter.
http.SetCookie(w, &http.Cookie{
Name: "usersession",
Value: player.GetUserSession().String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: "lobby-id",
Value: lobby.LobbyID,
Path: "/",
SameSite: http.SameSiteStrictMode,
})
}
}
func (handler *V1Handler) patchLobby(writer http.ResponseWriter, request *http.Request) {
userSession, err := GetUserSession(request)
if err != nil {
log.Printf("error getting user session: %v", err)
http.Error(writer, "no valid usersession supplied", http.StatusBadRequest)
return
}
if userSession == uuid.Nil {
http.Error(writer, "no usersession supplied", http.StatusBadRequest)
return
}
lobby := state.GetLobby(GetLobbyId(request))
if lobby == nil {
http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound)
return
}
if err := request.ParseForm(); err != nil {
http.Error(writer, fmt.Sprintf("error parsing request query into form (%s)", err), http.StatusBadRequest)
return
}
var requestErrors []string
// Uneditable properties
if request.Form.Get("custom_words") != "" {
requestErrors = append(requestErrors, "can't modify custom_words in existing lobby")
}
if request.Form.Get("language") != "" {
requestErrors = append(requestErrors, "can't modify language in existing lobby")
}
// Editable properties
maxPlayers, maxPlayersInvalid := ParseMaxPlayers(handler.cfg, request.Form.Get("max_players"))
drawingTime, drawingTimeInvalid := ParseDrawingTime(handler.cfg, request.Form.Get("drawing_time"))
rounds, roundsInvalid := ParseRounds(handler.cfg, request.Form.Get("rounds"))
customWordsPerTurn, customWordsPerTurnInvalid := ParseCustomWordsPerTurn(handler.cfg, request.Form.Get("custom_words_per_turn"))
clientsPerIPLimit, clientsPerIPLimitInvalid := ParseClientsPerIPLimit(handler.cfg, request.Form.Get("clients_per_ip_limit"))
publicLobby, publicLobbyInvalid := ParseBoolean("public", request.Form.Get("public"))
wordsPerTurn, wordsPerTurnInvalid := ParseWordsPerTurn(handler.cfg, request.Form.Get("words_per_turn"))
if wordsPerTurn < customWordsPerTurn {
wordsPerTurnInvalid = errors.New("words per turn must be greater than or equal to custom words per turn")
}
owner := lobby.GetOwner()
if owner == nil || owner.GetUserSession() != userSession {
http.Error(writer, "only the lobby owner can edit the lobby", http.StatusForbidden)
return
}
if maxPlayersInvalid != nil {
requestErrors = append(requestErrors, maxPlayersInvalid.Error())
}
if drawingTimeInvalid != nil {
requestErrors = append(requestErrors, drawingTimeInvalid.Error())
}
if roundsInvalid != nil {
requestErrors = append(requestErrors, roundsInvalid.Error())
} else {
currentRound := lobby.Round
if rounds < currentRound {
requestErrors = append(requestErrors, fmt.Sprintf("rounds must be greater than or equal to the current round (%d)", currentRound))
}
}
if customWordsPerTurnInvalid != nil {
requestErrors = append(requestErrors, customWordsPerTurnInvalid.Error())
}
if clientsPerIPLimitInvalid != nil {
requestErrors = append(requestErrors, clientsPerIPLimitInvalid.Error())
}
if publicLobbyInvalid != nil {
requestErrors = append(requestErrors, publicLobbyInvalid.Error())
}
if wordsPerTurnInvalid != nil {
requestErrors = append(requestErrors, wordsPerTurnInvalid.Error())
}
if len(requestErrors) != 0 {
http.Error(writer, strings.Join(requestErrors, ";"), http.StatusBadRequest)
return
}
// We synchronize as late as possible to avoid unnecessary lags.
// The previous code here isn't really prone to bugs due to lack of sync.
lobby.Synchronized(func() {
// While changing maxClientsPerIP and maxPlayers to a value lower than
// is currently being used makes little sense, we'll allow it, as it doesn't
// really break anything.
lobby.MaxPlayers = maxPlayers
lobby.CustomWordsPerTurn = customWordsPerTurn
lobby.ClientsPerIPLimit = clientsPerIPLimit
lobby.Public = publicLobby
lobby.Rounds = rounds
lobby.WordsPerTurn = wordsPerTurn
if lobby.State == game.Ongoing {
lobby.DrawingTimeNew = drawingTime
} else {
lobby.DrawingTime = drawingTime
}
lobbySettingsCopy := lobby.EditableLobbySettings
lobbySettingsCopy.DrawingTime = drawingTime
lobby.Broadcast(&game.Event{Type: game.EventTypeLobbySettingsChanged, Data: lobbySettingsCopy})
})
}
func (handler *V1Handler) getStats(writer http.ResponseWriter, _ *http.Request) {
if started, err := marshalToHTTPWriter(state.Stats(), writer); err != nil {
if !started {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
}
// SuggestedBrushSizes is suggested brush sizes value used for
// Lobbydata objects. A unit test makes sure these values are ordered
// and within the specified bounds.
var SuggestedBrushSizes = [4]uint8{8, 16, 24, 32}
// GameConstants are values that are lobby-independent and can't be changed via
// settings, neither at compile time nor at startup time.
type GameConstants struct {
// DrawingBoardBaseWidth is the internal canvas width and is needed for
// correctly up- / downscaling drawing instructions. Deprecated, but kept for compat.
DrawingBoardBaseWidth uint16 `json:"drawingBoardBaseWidth"`
// DrawingBoardBaseHeight is the internal canvas height and is needed for
// correctly up- / downscaling drawing instructions. Deprecated, but kept for compat.
DrawingBoardBaseHeight uint16 `json:"drawingBoardBaseHeight"`
// MinBrushSize is the minimum amount of pixels the brush can draw in.
MinBrushSize uint8 `json:"minBrushSize"`
// MaxBrushSize is the maximum amount of pixels the brush can draw in.
MaxBrushSize uint8 `json:"maxBrushSize"`
// CanvasColor is the initially (empty) color of the canvas.
CanvasColor uint8 `json:"canvasColor"`
// SuggestedBrushSizes are suggestions for the different brush sizes
// that the user can choose between. These brushes are guaranteed to
// be ordered from low to high and stay with the bounds.
SuggestedBrushSizes [4]uint8 `json:"suggestedBrushSizes"`
}
var GameConstantsData = &GameConstants{
DrawingBoardBaseWidth: game.DrawingBoardBaseWidth,
DrawingBoardBaseHeight: game.DrawingBoardBaseHeight,
MinBrushSize: game.MinBrushSize,
MaxBrushSize: game.MaxBrushSize,
CanvasColor: 0, /* White */
SuggestedBrushSizes: SuggestedBrushSizes,
}
// LobbyData is the data necessary for correctly configuring a lobby.
// While unofficial clients will probably need all of these values, the
// official webclient doesn't use all of them as of now.
type LobbyData struct {
game.SettingBounds
game.EditableLobbySettings
*GameConstants
IsWordpackRtl bool
}
// CreateLobbyData creates a ready to use LobbyData object containing data
// from the passed Lobby.
func CreateLobbyData(cfg *config.Config, lobby *game.Lobby) *LobbyData {
return &LobbyData{
SettingBounds: cfg.LobbySettingBounds,
EditableLobbySettings: lobby.EditableLobbySettings,
GameConstants: GameConstantsData,
IsWordpackRtl: lobby.IsWordpackRtl,
}
}
// GetUserSession accesses the usersession from an HTTP request and
// returns the session. The session can either be in the cookie or in
// the header. If no session can be found, an empty string is returned.
func GetUserSession(request *http.Request) (uuid.UUID, error) {
var userSession string
if sessionCookie, err := request.Cookie("usersession"); err == nil && sessionCookie.Value != "" {
userSession = sessionCookie.Value
} else {
userSession = request.Header.Get("Usersession")
}
if userSession == "" {
return uuid.Nil, nil
}
id, err := uuid.FromString(userSession)
if err != nil {
return uuid.Nil, fmt.Errorf("error parsing user session: %w", err)
}
return id, nil
}
// GetPlayer returns the player object that matches the usersession in the
// supplied HTTP request and lobby. If no user session is set, we return nil.
func GetPlayer(lobby *game.Lobby, request *http.Request) *game.Player {
userSession, err := GetUserSession(request)
if err != nil {
log.Printf("error getting user session: %v", err)
return nil
}
if userSession == uuid.Nil {
return nil
}
return lobby.GetPlayerBySession(userSession)
}
// GetPlayername either retrieves the playername from a cookie, the URL form.
// If no preferred name can be found, we return an empty string.
func GetPlayername(request *http.Request) string {
if err := request.ParseForm(); err == nil {
username := request.Form.Get("username")
if username != "" {
return username
}
}
if usernameCookie, err := request.Cookie("username"); err == nil {
if usernameCookie.Value != "" {
return usernameCookie.Value
}
}
return ""
}
// GetLobbyId returns either the lobby id from the URL or from a cookie.
func GetLobbyId(request *http.Request) string {
lobbyId := request.PathValue("lobby_id")
if lobbyId == "" {
cookie, _ := request.Cookie("lobby-id")
lobbyId = cookie.Value
}
return lobbyId
}
================================================
FILE: internal/api/ws.go
================================================
package api
import (
json "encoding/json"
"errors"
"fmt"
"log"
"net/http"
"runtime/debug"
"time"
"github.com/gofrs/uuid/v5"
"github.com/lxzan/gws"
"github.com/scribble-rs/scribble.rs/internal/game"
"github.com/scribble-rs/scribble.rs/internal/metrics"
"github.com/scribble-rs/scribble.rs/internal/state"
)
var (
ErrPlayerNotConnected = errors.New("player not connected")
upgrader = gws.NewUpgrader(&socketHandler{}, &gws.ServerOption{
Recovery: gws.Recovery,
ParallelEnabled: true,
PermessageDeflate: gws.PermessageDeflate{Enabled: true},
})
)
func (handler *V1Handler) websocketUpgrade(writer http.ResponseWriter, request *http.Request) {
userSession, err := GetUserSession(request)
if err != nil {
log.Printf("error getting user session: %v", err)
http.Error(writer, "no valid usersession supplied", http.StatusBadRequest)
return
}
if userSession == uuid.Nil {
// This issue can happen if you illegally request a websocket
// connection without ever having had a usersession or your
// client having deleted the usersession cookie.
http.Error(writer, "you don't have access to this lobby;usersession not set", http.StatusUnauthorized)
return
}
lobbyId := GetLobbyId(request)
if lobbyId == "" {
http.Error(writer, "lobby id missing", http.StatusBadRequest)
return
}
lobby := state.GetLobby(lobbyId)
if lobby == nil {
http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound)
return
}
lobby.Synchronized(func() {
player := lobby.GetPlayerBySession(userSession)
if player == nil {
http.Error(writer, "you don't have access to this lobby;usersession unknown", http.StatusUnauthorized)
return
}
socket, err := upgrader.Upgrade(writer, request)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
metrics.TrackPlayerConnect()
player.SetWebsocket(socket)
socket.Session().Store("player", player)
socket.Session().Store("lobby", lobby)
lobby.OnPlayerConnectUnsynchronized(player)
go socket.ReadLoop()
})
}
const (
pingInterval = 10 * time.Second
pingWait = 5 * time.Second
)
type socketHandler struct{}
func (c *socketHandler) resetDeadline(socket *gws.Conn) {
if err := socket.SetDeadline(time.Now().Add(pingInterval + pingWait)); err != nil {
log.Printf("error resetting deadline: %s\n", err)
}
}
func (c *socketHandler) OnOpen(socket *gws.Conn) {
c.resetDeadline(socket)
}
func extract(x any, _ bool) any {
return x
}
func (c *socketHandler) OnClose(socket *gws.Conn, _ error) {
defer socket.Session().Delete("player")
defer socket.Session().Delete("lobby")
player, ok := extract(socket.Session().Load("player")).(*game.Player)
if !ok {
return
}
lobby, ok := extract(socket.Session().Load("lobby")).(*game.Lobby)
if !ok {
return
}
metrics.TrackPlayerDisconnect()
lobby.OnPlayerDisconnect(player)
player.SetWebsocket(nil)
}
func (c *socketHandler) OnPing(socket *gws.Conn, _ []byte) {
c.resetDeadline(socket)
_ = socket.WritePong(nil)
}
func (c *socketHandler) OnPong(socket *gws.Conn, _ []byte) {
c.resetDeadline(socket)
}
func (c *socketHandler) OnMessage(socket *gws.Conn, message *gws.Message) {
defer message.Close()
defer c.resetDeadline(socket)
player, ok := extract(socket.Session().Load("player")).(*game.Player)
if !ok {
return
}
lobby, ok := extract(socket.Session().Load("lobby")).(*game.Lobby)
if !ok {
return
}
bytes := message.Bytes()
handleIncommingEvent(lobby, player, bytes)
}
func handleIncommingEvent(lobby *game.Lobby, player *game.Player, data []byte) {
defer func() {
if err := recover(); err != nil {
log.Printf("Error occurred in incomming event listener.\n\tError: %s\n\tPlayer: %s(%s)\nStack %s\n", err, player.Name, player.ID, string(debug.Stack()))
// FIXME Should this lead to a disconnect?
}
}()
var event game.EventTypeOnly
if err := json.Unmarshal(data, &event); err != nil {
log.Printf("Error unmarshalling message: %s\n", err)
err := WriteObject(player, game.Event{
Type: game.EventTypeSystemMessage,
Data: fmt.Sprintf("error parsing message, please report this issue via Github: %s!", err),
})
if err != nil {
log.Printf("Error sending errormessage: %s\n", err)
}
return
}
if err := lobby.HandleEvent(event.Type, data, player); err != nil {
log.Printf("Error handling event: %s\n", err)
}
}
func WriteObject(player *game.Player, object any) error {
socket := player.GetWebsocket()
if socket == nil || !player.Connected {
return ErrPlayerNotConnected
}
bytes, err := json.Marshal(object)
if err != nil {
return fmt.Errorf("error marshalling payload: %w", err)
}
// We write async, as broadcast always uses the queue. If we use write, the
// order will become messed up, potentially causing issues in the frontend.
socket.WriteAsync(gws.OpcodeText, bytes, func(err error) {
if err != nil {
log.Println("Error responding to player:", err.Error())
}
})
return nil
}
func WritePreparedMessage(player *game.Player, message *gws.Broadcaster) error {
socket := player.GetWebsocket()
if socket == nil || !player.Connected {
return ErrPlayerNotConnected
}
return message.Broadcast(socket)
}
================================================
FILE: internal/config/config.go
================================================
package config
import (
"fmt"
"log"
"maps"
"os"
"reflect"
"strings"
"time"
"github.com/caarlos0/env/v11"
"github.com/scribble-rs/scribble.rs/internal/game"
"github.com/subosito/gotenv"
)
type LobbySettingDefaults struct {
Public string `env:"PUBLIC"`
DrawingTime string `env:"DRAWING_TIME"`
Rounds string `env:"ROUNDS"`
MaxPlayers string `env:"MAX_PLAYERS"`
CustomWords string `env:"CUSTOM_WORDS"`
CustomWordsPerTurn string `env:"CUSTOM_WORDS_PER_TURN"`
ClientsPerIPLimit string `env:"CLIENTS_PER_IP_LIMIT"`
Language string `env:"LANGUAGE"`
ScoreCalculation string `env:"SCORE_CALCULATION"`
WordsPerTurn string `env:"WORDS_PER_TURN"`
}
type CORS struct {
AllowedOrigins []string `env:"ALLOWED_ORIGINS"`
AllowCredentials bool `env:"ALLOW_CREDENTIALS"`
}
type LobbyCleanup struct {
// Interval is the interval in which the cleanup routine will run. If set
// to `0`, the cleanup routine will be disabled.
Interval time.Duration `env:"INTERVAL"`
// PlayerInactivityThreshold is the time after which a player counts as
// inactivity and won't keep the lobby up. Note that cleaning up a lobby can
// therefore take up to Interval + PlayerInactivityThreshold.
PlayerInactivityThreshold time.Duration `env:"PLAYER_INACTIVITY_THRESHOLD"`
}
type Config struct {
// NetworkAddress is empty by default, since that implies listening on
// all interfaces. For development usecases, on windows for example, this
// is very annoying, as windows will nag you with firewall prompts.
NetworkAddress string `env:"NETWORK_ADDRESS"`
// RootPath is the path directly after the domain and before the
// scribblers paths. For example if you host scribblers on painting.com
// but already host a different website on that domain, then your API paths
// might have to look like this: painting.com/scribblers/v1
RootPath string `env:"ROOT_PATH"`
// RootURL is similar to RootPath, but contains only the protocol and
// domain. So it could be https://painting.com. This is required for some
// non critical functionality, such as metadata tags.
RootURL string `env:"ROOT_URL"`
// CanonicalURL specifies the original domain, in case we are accessing the
// site via some other domain, such as scribblers.fly.dev
CanonicalURL string `env:"CANONICAL_URL"`
// AllowIndexing will control whether the noindex, nofollow meta tag is
// added to the home page.
AllowIndexing bool `env:"ALLOW_INDEXING"`
// ServeDirectories is a map of `path` to `directory`. All directories are
// served under the given path.
ServeDirectories map[string]string `env:"SERVE_DIRECTORIES"`
CPUProfilePath string `env:"CPU_PROFILE_PATH"`
// LobbySettingDefaults is used for the server side rendering of the lobby
// creation page. It doesn't affect the default values of lobbies created
// via the API.
LobbySettingDefaults LobbySettingDefaults `envPrefix:"LOBBY_SETTING_DEFAULTS_"`
LobbySettingBounds game.SettingBounds `envPrefix:"LOBBY_SETTING_BOUNDS_"`
Port uint16 `env:"PORT"`
CORS CORS `envPrefix:"CORS_"`
LobbyCleanup LobbyCleanup `envPrefix:"LOBBY_CLEANUP_"`
}
var Default = Config{
Port: 8080,
LobbySettingDefaults: LobbySettingDefaults{
Public: "false",
DrawingTime: "120",
Rounds: "4",
MaxPlayers: "24",
CustomWordsPerTurn: "3",
ClientsPerIPLimit: "2",
Language: "english",
ScoreCalculation: "chill",
WordsPerTurn: "3",
},
LobbySettingBounds: game.SettingBounds{
MinDrawingTime: 60,
MaxDrawingTime: 300,
MinRounds: 1,
MaxRounds: 20,
MinMaxPlayers: 2,
MaxMaxPlayers: 24,
MinClientsPerIPLimit: 1,
MaxClientsPerIPLimit: 24,
MinCustomWordsPerTurn: 1,
MaxWordsPerTurn: 6,
MinWordsPerTurn: 1,
},
CORS: CORS{
AllowedOrigins: []string{"*"},
AllowCredentials: false,
},
LobbyCleanup: LobbyCleanup{
Interval: 90 * time.Second,
PlayerInactivityThreshold: 75 * time.Second,
},
}
// Load loads the configuration from the environment. If a .env file is
// available, it will be loaded as well. Values found in the environment
// will overwrite whatever is load from the .env file.
func Load() (*Config, error) {
envVars := make(map[string]string)
dotEnvPath := ".env"
if _, err := os.Stat(dotEnvPath); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("error checking for existence of .env file: %w", err)
}
} else {
envFileContent, err := gotenv.Read(dotEnvPath)
if err != nil {
return nil, fmt.Errorf("error reading .env file: %w", err)
}
maps.Copy(envVars, envFileContent)
}
// Add local environment variables to EnvVars map
for _, keyValuePair := range os.Environ() {
pair := strings.SplitN(keyValuePair, "=", 2)
// For some reason, gitbash can contain the variable `=::=::\` which
// gives us a pair where the first entry is empty.
if pair[0] == "" {
continue
}
envVars[pair[0]] = pair[1]
}
config := Default
if err := env.ParseWithOptions(&config, env.Options{
Environment: envVars,
OnSet: func(key string, value any, isDefault bool) {
if !reflect.ValueOf(value).IsZero() {
log.Printf("Setting '%s' to '%v' (isDefault: %v)\n", key, value, isDefault)
}
},
}); err != nil {
return nil, fmt.Errorf("error parsing environment variables: %w", err)
}
// Prevent user error and let the code decide when we need slashes.
config.RootURL = strings.TrimSuffix(config.RootURL, "/")
if config.CanonicalURL == "" {
config.CanonicalURL = config.RootURL
}
config.RootPath = strings.Trim(config.RootPath, "/")
return &config, nil
}
================================================
FILE: internal/frontend/doc.go
================================================
// Package frontend contains the HTTP endpoints for delivering the official
// web client. In order to register the endpoints you have to call SetupRoutes.
package frontend
================================================
FILE: internal/frontend/http.go
================================================
package frontend
import (
"embed"
"encoding/hex"
"fmt"
"hash"
"html/template"
"net/http"
"os"
"path"
"strings"
"github.com/gofrs/uuid/v5"
"github.com/scribble-rs/scribble.rs/internal/translations"
)
var (
//go:embed templates/*
templateFS embed.FS
pageTemplates *template.Template
//go:embed resources/*
frontendResourcesFS embed.FS
)
func init() {
var err error
pageTemplates, err = template.ParseFS(templateFS, "templates/*")
if err != nil {
panic(fmt.Errorf("error loading templates: %w", err))
}
}
// BasePageConfig is data that all pages require to function correctly, no matter
// whether error page or lobby page.
type BasePageConfig struct {
checksums map[string]string
hash hash.Hash
// Version is the tagged source code version of this build. Can be empty for dev
// builds. Untagged commits will be of format `tag-N-gSHA`.
Version string `json:"version"`
// Commit that was deployed, if we didn't deploy a concrete tag.
Commit string `json:"commit"`
// RootPath is the path directly after the domain and before the
// scribble.rs paths. For example if you host scribblers on painting.com
// but already host a different website, then your API paths might have to
// look like this: painting.com/scribblers/v1.
RootPath string `json:"rootPath"`
// RootURL is similar to RootPath, but contains only the protocol and
// domain. So it could be https://painting.com. This is required for some
// non critical functionality, such as metadata tags.
RootURL string `json:"rootUrl"`
// CanonicalURL specifies the original domain, in case we are accessing the
// site via some other domain, such as scribblers.fly.dev
CanonicalURL string `json:"canonicalUrl"`
// AllowIndexing will control whether the noindex, nofollow meta tag is
// added to the home page.
AllowIndexing bool `env:"ALLOW_INDEXING"`
}
var fallbackChecksum = uuid.Must(uuid.NewV4()).String()
func (baseConfig *BasePageConfig) Hash(key string, bytes []byte) error {
_, alreadyExists := baseConfig.checksums[key]
if alreadyExists {
return fmt.Errorf("duplicate hash key '%s'", key)
}
if _, err := baseConfig.hash.Write(bytes); err != nil {
return fmt.Errorf("error hashing '%s': %w", key, err)
}
baseConfig.checksums[key] = hex.EncodeToString(baseConfig.hash.Sum(nil))
baseConfig.hash.Reset()
return nil
}
// CacheBust is a string that is appended to all resources to prevent
// browsers from using cached data of a previous version, but still have
// long lived max age values.
func (baseConfig *BasePageConfig) withCacheBust(file string) string {
checksum, found := baseConfig.checksums[file]
if !found {
// No need to crash over
return fmt.Sprintf("%s?cache_bust=%s", file, fallbackChecksum)
}
return fmt.Sprintf("%s?cache_bust=%s", file, checksum)
}
func (baseConfig *BasePageConfig) WithCacheBust(file string) template.HTMLAttr {
return template.HTMLAttr(baseConfig.withCacheBust(file))
}
func (handler *SSRHandler) cspMiddleware(handleFunc http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Security-Policy", "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:")
handleFunc.ServeHTTP(w, r)
}
}
// SetupRoutes registers the official webclient endpoints with the http package.
func (handler *SSRHandler) SetupRoutes(register func(string, string, http.HandlerFunc)) {
registerWithCsp := func(s1, s2 string, hf http.HandlerFunc) {
register(s1, s2, handler.cspMiddleware(hf))
}
var genericFileHandler http.HandlerFunc
if dir := handler.cfg.ServeDirectories[""]; dir != "" {
delete(handler.cfg.ServeDirectories, "")
fileServer := http.FileServer(http.FS(os.DirFS(dir)))
genericFileHandler = fileServer.ServeHTTP
}
for route, dir := range handler.cfg.ServeDirectories {
fileServer := http.FileServer(http.FS(os.DirFS(dir)))
fileHandler := http.StripPrefix(
"/"+path.Join(handler.cfg.RootPath, route)+"/",
http.HandlerFunc(fileServer.ServeHTTP),
).ServeHTTP
registerWithCsp(
// Trailing slash means wildcard.
"GET", path.Join(handler.cfg.RootPath, route)+"/",
fileHandler,
)
}
indexHandler := handler.cspMiddleware(handler.indexPageHandler)
register("GET", handler.cfg.RootPath,
http.StripPrefix(
"/"+handler.cfg.RootPath,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "" || r.URL.Path == "/" {
indexHandler(w, r)
return
}
if genericFileHandler != nil {
genericFileHandler.ServeHTTP(w, r)
}
})).ServeHTTP)
fileServer := http.FileServer(http.FS(frontendResourcesFS))
registerWithCsp(
"GET", path.Join(handler.cfg.RootPath, "resources", "{file}"),
http.StripPrefix(
"/"+handler.cfg.RootPath,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Duration of 1 year, since we use cachebusting anyway.
w.Header().Set("Cache-Control", "public, max-age=31536000")
fileServer.ServeHTTP(w, r)
}),
).ServeHTTP,
)
registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby.js"), handler.lobbyJs)
registerWithCsp("GET", path.Join(handler.cfg.RootPath, "index.js"), handler.indexJs)
registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}"), handler.ssrEnterLobby)
registerWithCsp("POST", path.Join(handler.cfg.RootPath, "lobby"), handler.ssrCreateLobby)
}
// errorPageData represents the data that error.html requires to be displayed.
type errorPageData struct {
*BasePageConfig
// ErrorMessage displayed on the page.
ErrorMessage string
Translation *translations.Translation
Locale string
}
// userFacingError will return the occurred error as a custom html page to the caller.
func (handler *SSRHandler) userFacingError(w http.ResponseWriter, errorMessage string, translation *translations.Translation) {
err := pageTemplates.ExecuteTemplate(w, "error-page", &errorPageData{
BasePageConfig: handler.basePageConfig,
ErrorMessage: errorMessage,
Translation: translation,
})
// This should never happen, but if it does, something is very wrong.
if err != nil {
panic(err)
}
}
func isHumanAgent(userAgent string) bool {
return strings.Contains(userAgent, "gecko") ||
strings.Contains(userAgent, "chrome") ||
strings.Contains(userAgent, "opera") ||
strings.Contains(userAgent, "safari")
}
================================================
FILE: internal/frontend/index.go
================================================
package frontend
import (
//nolint:gosec //We just use this for cache busting, so it's secure enough
"crypto/md5"
"errors"
"fmt"
"log"
"net/http"
txtTemplate "text/template"
"github.com/scribble-rs/scribble.rs/internal/api"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/game"
"github.com/scribble-rs/scribble.rs/internal/state"
"github.com/scribble-rs/scribble.rs/internal/translations"
"github.com/scribble-rs/scribble.rs/internal/version"
"golang.org/x/text/cases"
"golang.org/x/text/language"
_ "embed"
)
//go:embed lobby.js
var lobbyJsRaw string
//go:embed index.js
var indexJsRaw string
type indexJsData struct {
*BasePageConfig
Translation *translations.Translation
Locale string
}
// This file contains the API for the official web client.
type SSRHandler struct {
cfg *config.Config
basePageConfig *BasePageConfig
lobbyJsRawTemplate *txtTemplate.Template
indexJsRawTemplate *txtTemplate.Template
}
func NewHandler(cfg *config.Config) (*SSRHandler, error) {
basePageConfig := &BasePageConfig{
checksums: make(map[string]string),
hash: md5.New(),
Version: version.Version,
Commit: version.Commit,
RootURL: cfg.RootURL,
CanonicalURL: cfg.CanonicalURL,
AllowIndexing: cfg.AllowIndexing,
}
if cfg.RootPath != "" {
basePageConfig.RootPath = "/" + cfg.RootPath
}
if basePageConfig.CanonicalURL == "" {
basePageConfig.CanonicalURL = basePageConfig.RootURL
}
indexJsRawTemplate, err := txtTemplate.
New("index-js").
Parse(indexJsRaw)
if err != nil {
return nil, fmt.Errorf("error parsing index js template: %w", err)
}
lobbyJsRawTemplate, err := txtTemplate.
New("lobby-js").
Parse(lobbyJsRaw)
if err != nil {
return nil, fmt.Errorf("error parsing lobby js template: %w", err)
}
lobbyJsRawTemplate.AddParseTree("footer", pageTemplates.Tree)
entries, err := frontendResourcesFS.ReadDir("resources")
if err != nil {
return nil, fmt.Errorf("error reading resource directory: %w", err)
}
//nolint:gosec //We just use this for cache busting, so it's secure enough
for _, entry := range entries {
bytes, err := frontendResourcesFS.ReadFile("resources/" + entry.Name())
if err != nil {
return nil, fmt.Errorf("error reading resource %s: %w", entry.Name(), err)
}
if err := basePageConfig.Hash(entry.Name(), bytes); err != nil {
return nil, fmt.Errorf("error hashing resource %s: %w", entry.Name(), err)
}
}
if err := basePageConfig.Hash("index.js", []byte(indexJsRaw)); err != nil {
return nil, fmt.Errorf("error hashing: %w", err)
}
if err := basePageConfig.Hash("lobby.js", []byte(lobbyJsRaw)); err != nil {
return nil, fmt.Errorf("error hashing: %w", err)
}
handler := &SSRHandler{
cfg: cfg,
basePageConfig: basePageConfig,
lobbyJsRawTemplate: lobbyJsRawTemplate,
indexJsRawTemplate: indexJsRawTemplate,
}
return handler, nil
}
func (handler *SSRHandler) indexJs(writer http.ResponseWriter, request *http.Request) {
translation, locale := determineTranslation(request)
pageData := &indexJsData{
BasePageConfig: handler.basePageConfig,
Translation: translation,
Locale: locale,
}
writer.Header().Set("Content-Type", "text/javascript")
// Duration of 1 year, since we use cachebusting anyway.
writer.Header().Set("Cache-Control", "public, max-age=31536000")
writer.WriteHeader(http.StatusOK)
if err := handler.indexJsRawTemplate.ExecuteTemplate(writer, "index-js", pageData); err != nil {
log.Printf("error templating JS: %s\n", err)
}
}
// indexPageHandler servers the default page for scribble.rs, which is the
// page to create or join a lobby.
func (handler *SSRHandler) indexPageHandler(writer http.ResponseWriter, request *http.Request) {
translation, locale := determineTranslation(request)
createPageData := handler.createDefaultIndexPageData()
createPageData.Translation = translation
createPageData.Locale = locale
api.SetDiscordCookies(writer, request)
discordInstanceId := api.GetDiscordInstanceId(request)
if discordInstanceId != "" {
lobby := state.GetLobby(discordInstanceId)
if lobby != nil {
handler.ssrEnterLobbyNoChecks(lobby, writer, request,
func() *game.Player {
return api.GetPlayer(lobby, request)
})
return
}
}
err := pageTemplates.ExecuteTemplate(writer, "index", createPageData)
if err != nil {
log.Printf("Error templating home page: %s\n", err)
}
}
func (handler *SSRHandler) createDefaultIndexPageData() *IndexPageData {
return &IndexPageData{
BasePageConfig: handler.basePageConfig,
SettingBounds: handler.cfg.LobbySettingBounds,
Languages: game.SupportedLanguages,
ScoreCalculations: game.SupportedScoreCalculations,
LobbySettingDefaults: handler.cfg.LobbySettingDefaults,
}
}
// IndexPageData defines all non-static data for the lobby create page.
type IndexPageData struct {
*BasePageConfig
config.LobbySettingDefaults
game.SettingBounds
Translation *translations.Translation
Locale string
Errors []string
Languages map[string]string
ScoreCalculations []string
}
// ssrCreateLobby allows creating a lobby, optionally returning errors that
// occurred during creation.
func (handler *SSRHandler) ssrCreateLobby(writer http.ResponseWriter, request *http.Request) {
if err := request.ParseForm(); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
scoreCalculation, scoreCalculationInvalid := api.ParseScoreCalculation(request.Form.Get("score_calculation"))
languageRawValue := request.Form.Get("language")
languageData, languageKey, languageInvalid := api.ParseLanguage(languageRawValue)
drawingTime, drawingTimeInvalid := api.ParseDrawingTime(handler.cfg, request.Form.Get("drawing_time"))
rounds, roundsInvalid := api.ParseRounds(handler.cfg, request.Form.Get("rounds"))
maxPlayers, maxPlayersInvalid := api.ParseMaxPlayers(handler.cfg, request.Form.Get("max_players"))
customWordsPerTurn, customWordsPerTurnInvalid := api.ParseCustomWordsPerTurn(handler.cfg, request.Form.Get("custom_words_per_turn"))
clientsPerIPLimit, clientsPerIPLimitInvalid := api.ParseClientsPerIPLimit(handler.cfg, request.Form.Get("clients_per_ip_limit"))
publicLobby, publicLobbyInvalid := api.ParseBoolean("public", request.Form.Get("public"))
wordsPerTurn, wordsPerTurnInvalid := api.ParseWordsPerTurn(handler.cfg, request.Form.Get("words_per_turn"))
if wordsPerTurn < customWordsPerTurn {
wordsPerTurnInvalid = errors.New("words per turn must be greater than or equal to custom words per turn")
}
var lowercaser cases.Caser
if languageInvalid != nil {
lowercaser = cases.Lower(language.English)
} else {
lowercaser = languageData.Lowercaser()
}
customWords, customWordsInvalid := api.ParseCustomWords(lowercaser, request.Form.Get("custom_words"))
api.SetDiscordCookies(writer, request)
// Prevent resetting the form, since that would be annoying as hell.
pageData := IndexPageData{
BasePageConfig: handler.basePageConfig,
SettingBounds: handler.cfg.LobbySettingBounds,
LobbySettingDefaults: config.LobbySettingDefaults{
Public: request.Form.Get("public"),
DrawingTime: request.Form.Get("drawing_time"),
Rounds: request.Form.Get("rounds"),
MaxPlayers: request.Form.Get("max_players"),
CustomWords: request.Form.Get("custom_words"),
CustomWordsPerTurn: request.Form.Get("custom_words_per_turn"),
ClientsPerIPLimit: request.Form.Get("clients_per_ip_limit"),
Language: request.Form.Get("language"),
ScoreCalculation: request.Form.Get("score_calculation"),
WordsPerTurn: request.Form.Get("words_per_turn"),
},
Languages: game.SupportedLanguages,
ScoreCalculations: game.SupportedScoreCalculations,
}
if scoreCalculationInvalid != nil {
pageData.Errors = append(pageData.Errors, scoreCalculationInvalid.Error())
}
if languageInvalid != nil {
pageData.Errors = append(pageData.Errors, languageInvalid.Error())
}
if drawingTimeInvalid != nil {
pageData.Errors = append(pageData.Errors, drawingTimeInvalid.Error())
}
if roundsInvalid != nil {
pageData.Errors = append(pageData.Errors, roundsInvalid.Error())
}
if maxPlayersInvalid != nil {
pageData.Errors = append(pageData.Errors, maxPlayersInvalid.Error())
}
if customWordsInvalid != nil {
pageData.Errors = append(pageData.Errors, customWordsInvalid.Error())
}
if customWordsPerTurnInvalid != nil {
pageData.Errors = append(pageData.Errors, customWordsPerTurnInvalid.Error())
} else {
if languageRawValue == "custom" && len(customWords) == 0 {
pageData.Errors = append(pageData.Errors, "custom words must be provided when using custom language")
}
}
if clientsPerIPLimitInvalid != nil {
pageData.Errors = append(pageData.Errors, clientsPerIPLimitInvalid.Error())
}
if publicLobbyInvalid != nil {
pageData.Errors = append(pageData.Errors, publicLobbyInvalid.Error())
}
if wordsPerTurnInvalid != nil {
pageData.Errors = append(pageData.Errors, wordsPerTurnInvalid.Error())
}
translation, locale := determineTranslation(request)
pageData.Translation = translation
pageData.Locale = locale
if len(pageData.Errors) != 0 {
err := pageTemplates.ExecuteTemplate(writer, "index", pageData)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
playerName := api.GetPlayername(request)
var lobbyId string
discordInstanceId := api.GetDiscordInstanceId(request)
if discordInstanceId != "" {
lobbyId = discordInstanceId
// Workaround, since the discord proxy potentially always has the same
// IP address, preventing all players from connecting.
clientsPerIPLimit = maxPlayers
}
lobbySettings := &game.EditableLobbySettings{
Rounds: rounds,
DrawingTime: drawingTime,
MaxPlayers: maxPlayers,
CustomWordsPerTurn: customWordsPerTurn,
ClientsPerIPLimit: clientsPerIPLimit,
Public: publicLobby,
WordsPerTurn: wordsPerTurn,
}
player, lobby, err := game.CreateLobby(lobbyId, playerName, languageKey,
lobbySettings, customWords, scoreCalculation)
if err != nil {
pageData.Errors = append(pageData.Errors, err.Error())
if err := pageTemplates.ExecuteTemplate(writer, "index", pageData); err != nil {
handler.userFacingError(writer, err.Error(), translation)
}
return
}
lobby.WriteObject = api.WriteObject
lobby.WritePreparedMessage = api.WritePreparedMessage
player.SetLastKnownAddress(api.GetIPAddressFromRequest(request))
api.SetGameplayCookies(writer, request, player, lobby)
// We only add the lobby if we could do all necessary pre-steps successfully.
state.AddLobby(lobby)
// Workaround for discord activity case not correctly being able to read
// user session, as the cookie isn't being passed.
if discordInstanceId != "" {
handler.ssrEnterLobbyNoChecks(lobby, writer, request,
func() *game.Player {
return player
})
return
}
http.Redirect(writer, request, handler.basePageConfig.RootPath+"/lobby/"+lobby.LobbyID, http.StatusFound)
}
================================================
FILE: internal/frontend/index.js
================================================
const discordInstanceId = getCookie("discord-instance-id");
const rootPath = `${discordInstanceId ? ".proxy/" : ""}{{.RootPath}}`;
Array.from(document.getElementsByClassName("number-input")).forEach(
(number_input) => {
const input = number_input.children.item(1);
const decrement_button = number_input.children.item(0);
decrement_button.addEventListener("click", function () {
input.stepDown();
});
const increment_button = number_input.children.item(2);
increment_button.addEventListener("click", function () {
input.stepUp();
});
},
);
// We'll keep using the ssr endpoint for now. With this listener, we
// can fake in the form data for "public" depending on which button
// we submitted via. This is a dirty hack, but works for now.
document.getElementById("lobby-create").addEventListener("submit", (event) => {
const check_box = document.getElementById("public-check-box");
if (event.submitter.id === "create-public") {
check_box.value = "true";
check_box.setAttribute("checked", "");
} else {
check_box.value = "false";
check_box.removeAttribute("checked");
}
return true;
});
const lobby_list_placeholder = document.getElementById(
"lobby-list-placeholder-text",
);
const lobby_list_loading_placeholder = document.getElementById(
"lobby-list-placeholder-loading",
);
const lobby_list = document.getElementById("lobby-list");
lobby_list_placeholder.innerHTML =
'<b>{{.Translation.Get "no-lobbies-yet"}}</b>';
const getLobbies = () => {
return new Promise((resolve, reject) => {
fetch(`${rootPath}/v1/lobby`)
.then((response) => {
response.json().then(resolve);
})
.catch(reject);
});
};
const set_lobby_list_placeholder = (text, visible) => {
if (visible) {
lobby_list_placeholder.style.display = "flex";
lobby_list_placeholder.innerHTML = "<b>" + text + "<b>";
} else {
lobby_list_placeholder.style.display = "none";
}
};
const set_lobby_list_loading = (loading) => {
if (loading) {
set_lobby_list_placeholder("", false);
lobby_list_loading_placeholder.style.display = "flex";
} else {
lobby_list_loading_placeholder.style.display = "none";
}
};
const language_to_flag = (language) => {
switch (language) {
case "english":
return "\u{1f1fa}\u{1f1f8}";
case "english_gb":
return "\u{1f1ec}\u{1f1e7}";
case "german":
return "\u{1f1e9}\u{1f1ea}";
case "ukrainian":
return "\u{1f1fa}\u{1f1e6}";
case "russian":
return "\u{1f1f7}\u{1f1fa}";
case "italian":
return "\u{1f1ee}\u{1f1f9}";
case "french":
return "\u{1f1eb}\u{1f1f7}";
case "dutch":
return "\u{1f1f3}\u{1f1f1}";
case "polish":
return "\u{1f1f5}\u{1f1f1}";
case "hebrew":
return "\u{1f1ee}\u{1f1f1}";
}
};
const set_lobbies = (lobbies, visible) => {
const new_lobby_nodes = lobbies.map((lobby) => {
const lobby_list_item = document.createElement("div");
lobby_list_item.className = "lobby-list-item";
const language_flag = document.createElement("span");
language_flag.className = "language-flag";
language_flag.setAttribute("title", lobby.wordpack);
language_flag.setAttribute("english", lobby.wordpack);
language_flag.innerText = language_to_flag(lobby.wordpack);
const lobby_list_rows = document.createElement("div");
lobby_list_rows.className = "lobby-list-rows";
const lobby_list_row_a = document.createElement("div");
lobby_list_row_a.className = "lobby-list-row";
const new_custom_tag = (text) => {
const tag = document.createElement("span");
tag.className = "custom-tag";
tag.innerText = text;
return tag;
};
if (lobby.customWords) {
lobby_list_row_a.appendChild(
new_custom_tag('{{.Translation.Get "custom-words"}}'),
);
}
if (lobby.state === "ongoing") {
lobby_list_row_a.appendChild(
new_custom_tag('{{.Translation.Get "ongoing"}}'),
);
}
if (lobby.state === "gameover") {
lobby_list_row_a.appendChild(
new_custom_tag('{{.Translation.Get "game-over-lobby"}}'),
);
}
if (lobby.scoring === "chill") {
lobby_list_row_a.appendChild(
new_custom_tag('{{.Translation.Get "chill"}}'),
);
} else if (lobby.scoring === "competitive") {
lobby_list_row_a.appendChild(
new_custom_tag('{{.Translation.Get "competitive"}}'),
);
}
const lobby_list_row_b = document.createElement("div");
lobby_list_row_b.className = "lobby-list-row";
const create_info_pair = (icon, text) => {
const element = document.createElement("div");
element.className = "lobby-list-item-info-pair";
const image = document.createElement("img");
image.className = "lobby-list-item-icon lobby-list-icon-loading";
image.setAttribute("loading", "lazy");
image.addEventListener("load", function () {
image.classList.remove("lobby-list-icon-loading");
});
image.setAttribute("src", icon);
const span = document.createElement("span");
span.innerText = text;
element.replaceChildren(image, span);
return element;
};
const user_pair = create_info_pair(
`{{.RootPath}}/resources/{{.WithCacheBust "user.svg"}}`,
`${lobby.playerCount}/${lobby.maxPlayers}`,
);
const round_pair = create_info_pair(
`{{.RootPath}}/resources/{{.WithCacheBust "round.svg"}}`,
`${lobby.round}/${lobby.rounds}`,
);
const time_pair = create_info_pair(
`{{.RootPath}}/resources/{{.WithCacheBust "clock.svg"}}`,
`${lobby.drawingTime}`,
);
lobby_list_row_b.replaceChildren(user_pair, round_pair, time_pair);
lobby_list_rows.replaceChildren(lobby_list_row_a, lobby_list_row_b);
const join_button = document.createElement("button");
join_button.className = "join-button";
join_button.innerText = '{{.Translation.Get "join"}}';
join_button.addEventListener("click", (event) => {
window.location.href = `{{.RootPath}}/lobby/${lobby.lobbyId}`;
});
lobby_list_item.replaceChildren(
language_flag,
lobby_list_rows,
join_button,
);
return lobby_list_item;
});
lobby_list.replaceChildren(...new_lobby_nodes);
if (lobbies && lobbies.length > 0 && visible) {
lobby_list.style.display = "flex";
set_lobby_list_placeholder("", false);
} else {
lobby_list.style.display = "none";
set_lobby_list_placeholder(
'{{.Translation.Get "no-lobbies-yet"}}',
true,
);
}
};
const refresh_lobby_list = () => {
set_lobbies([], false);
set_lobby_list_loading(true);
getLobbies()
.then((data) => {
set_lobbies(data, true);
})
.catch((err) => {
set_lobby_list_placeholder(err, true);
})
.finally(() => {
set_lobby_list_loading(false);
});
};
refresh_lobby_list();
document
.getElementById("refresh-lobby-list-button")
.addEventListener("click", refresh_lobby_list);
function getCookie(name) {
let cookie = {};
document.cookie.split(";").forEach(function (el) {
let split = el.split("=");
cookie[split[0].trim()] = split.slice(1).join("=");
});
return cookie[name];
}
// Makes sure, that navigating back after creating a lobby also shows it in the list.
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
refresh_lobby_list();
}
});
================================================
FILE: internal/frontend/lobby.go
================================================
package frontend
import (
"log"
"net/http"
"strings"
"github.com/scribble-rs/scribble.rs/internal/api"
"github.com/scribble-rs/scribble.rs/internal/game"
"github.com/scribble-rs/scribble.rs/internal/state"
"github.com/scribble-rs/scribble.rs/internal/translations"
"golang.org/x/text/language"
)
type lobbyPageData struct {
*BasePageConfig
*api.LobbyData
Translation *translations.Translation
Locale string
}
type lobbyJsData struct {
*BasePageConfig
*api.GameConstants
Translation *translations.Translation
Locale string
}
func (handler *SSRHandler) lobbyJs(writer http.ResponseWriter, request *http.Request) {
translation, locale := determineTranslation(request)
pageData := &lobbyJsData{
BasePageConfig: handler.basePageConfig,
GameConstants: api.GameConstantsData,
Translation: translation,
Locale: locale,
}
writer.Header().Set("Content-Type", "text/javascript")
// Duration of 1 year, since we use cachebusting anyway.
writer.Header().Set("Cache-Control", "public, max-age=31536000")
writer.WriteHeader(http.StatusOK)
if err := handler.lobbyJsRawTemplate.ExecuteTemplate(writer, "lobby-js", pageData); err != nil {
log.Printf("error templating JS: %s\n", err)
}
}
// ssrEnterLobby opens a lobby, either opening it directly or asking for a lobby.
func (handler *SSRHandler) ssrEnterLobby(writer http.ResponseWriter, request *http.Request) {
translation, _ := determineTranslation(request)
lobby := state.GetLobby(request.PathValue("lobby_id"))
if lobby == nil {
handler.userFacingError(writer, translation.Get("lobby-doesnt-exist"), translation)
return
}
userAgent := strings.ToLower(request.UserAgent())
if !isHumanAgent(userAgent) {
writer.WriteHeader(http.StatusForbidden)
handler.userFacingError(writer, translation.Get("forbidden"), translation)
return
}
handler.ssrEnterLobbyNoChecks(lobby, writer, request,
func() *game.Player {
return api.GetPlayer(lobby, request)
})
}
func (handler *SSRHandler) ssrEnterLobbyNoChecks(
lobby *game.Lobby,
writer http.ResponseWriter,
request *http.Request,
getPlayer func() *game.Player,
) {
translation, locale := determineTranslation(request)
requestAddress := api.GetIPAddressFromRequest(request)
api.SetDiscordCookies(writer, request)
var pageData *lobbyPageData
lobby.Synchronized(func() {
player := getPlayer()
if player == nil {
if !lobby.HasFreePlayerSlot() {
handler.userFacingError(writer, translation.Get("lobby-full"), translation)
return
}
if !lobby.CanIPConnect(requestAddress) {
handler.userFacingError(writer, translation.Get("lobby-ip-limit-excceeded"), translation)
return
}
newPlayer := lobby.JoinPlayer(api.GetPlayername(request))
newPlayer.SetLastKnownAddress(requestAddress)
api.SetGameplayCookies(writer, request, newPlayer, lobby)
} else {
if player.Connected && player.GetWebsocket() != nil {
handler.userFacingError(writer, translation.Get("lobby-open-tab-exists"), translation)
return
}
player.SetLastKnownAddress(requestAddress)
api.SetGameplayCookies(writer, request, player, lobby)
}
pageData = &lobbyPageData{
BasePageConfig: handler.basePageConfig,
LobbyData: api.CreateLobbyData(handler.cfg, lobby),
Translation: translation,
Locale: locale,
}
})
// If the pagedata isn't initialized, it means the synchronized block has exited.
// In this case we don't want to template the lobby, since an error has occurred
// and probably already has been handled.
if pageData != nil {
if err := pageTemplates.ExecuteTemplate(writer, "lobby-page", pageData); err != nil {
log.Printf("Error templating lobby: %s\n", err)
}
}
}
func determineTranslation(r *http.Request) (*translations.Translation, string) {
languageTags, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))
if err == nil {
for _, languageTag := range languageTags {
fullLanguageIdentifier := languageTag.String()
fullLanguageIdentifierLowercased := strings.ToLower(fullLanguageIdentifier)
translation := translations.GetLanguage(fullLanguageIdentifierLowercased)
if translation != nil {
return translation, fullLanguageIdentifierLowercased
}
baseLanguageIdentifier, _ := languageTag.Base()
baseLanguageIdentifierLowercased := strings.ToLower(baseLanguageIdentifier.String())
translation = translations.GetLanguage(baseLanguageIdentifierLowercased)
if translation != nil {
return translation, baseLanguageIdentifierLowercased
}
}
}
return translations.DefaultTranslation, "en-us"
}
================================================
FILE: internal/frontend/lobby.js
================================================
String.prototype.format = function () {
return [...arguments].reduce((p, c) => p.replace(/%s/, c), this);
};
const discordInstanceId = getCookie("discord-instance-id");
const rootPath = `${discordInstanceId ? ".proxy/" : ""}{{.RootPath}}`;
let socketIsConnecting = false;
let hasSocketEverConnected = false;
let socket;
const reconnectDialogId = "reconnect-dialog";
function showReconnectDialogIfNotShown() {
const previousReconnectDialog = document.getElementById(reconnectDialogId);
//Since the content is constant, there's no need to ever show two.
if (
previousReconnectDialog === undefined ||
previousReconnectDialog === null
) {
showTextDialog(
reconnectDialogId,
'{{.Translation.Get "connection-lost"}}',
`{{.Translation.Get "connection-lost-text"}}`,
);
}
}
//Makes sure that the server notices that the player disconnects.
//Otherwise a refresh (on chromium based browsers) can lead to the server
//thinking that there's already an open tab with this lobby.
window.onbeforeunload = () => {
//Avoid unintentionally reestablishing connection.
socket.onclose = null;
if (socket) {
socket.close();
}
};
const messageInput = document.getElementById("message-input");
const playerContainer = document.getElementById("player-container");
const wordContainer = document.getElementById("word-container");
const chat = document.getElementById("chat");
const messageContainer = document.getElementById("message-container");
const roundSpan = document.getElementById("rounds");
const maxRoundSpan = document.getElementById("max-rounds");
const timeLeftValue = document.getElementById("time-left-value");
const drawingBoard = document.getElementById("drawing-board");
const centerDialogs = document.getElementById("center-dialogs");
const waitChooseDialog = document.getElementById("waitchoose-dialog");
const waitChooseDrawerSpan = document.getElementById("waitchoose-drawer");
const namechangeDialog = document.getElementById("namechange-dialog");
const namechangeFieldStartDialog = document.getElementById(
"namechange-field-start-dialog",
);
const namechangeField = document.getElementById("namechange-field");
const lobbySettingsButton = document.getElementById("lobby-settings-button");
const lobbySettingsDialog = document.getElementById("lobbysettings-dialog");
const startDialog = document.getElementById("start-dialog");
const forceStartButton = document.getElementById("force-start-button");
const gameOverDialog = document.getElementById("game-over-dialog");
const gameOverDialogTitle = document.getElementById("game-over-dialog-title");
const gameOverScoreboard = document.getElementById("game-over-scoreboard");
const forceRestartButton = document.getElementById("force-restart-button");
const wordDialog = document.getElementById("word-dialog");
const wordPreSelected = document.getElementById("word-preselected");
const wordButtonContainer = document.getElementById("word-button-container");
const kickDialog = document.getElementById("kick-dialog");
const kickDialogPlayers = document.getElementById("kick-dialog-players");
const soundToggleLabel = document.getElementById("sound-toggle-label");
let sound = localStorage.getItem("sound") !== "false";
updateSoundIcon();
const penToggleLabel = document.getElementById("pen-pressure-toggle-label");
let penPressure = localStorage.getItem("penPressure") !== "false";
updateTogglePenIcon();
function showTextDialog(id, title, message) {
const messageNode = document.createElement("span");
messageNode.innerText = message;
showDialog(id, title, messageNode);
}
const menu = document.getElementById("menu");
function hideMenu() {
menu.hidePopover();
}
function showDialog(id, title, contentNode, buttonBar) {
hideMenu();
const newDialog = document.createElement("div");
newDialog.classList.add("center-dialog");
if (id && id !== "") {
newDialog.id = id;
}
const dialogTitle = document.createElement("span");
dialogTitle.classList.add("dialog-title");
dialogTitle.innerText = title;
newDialog.appendChild(dialogTitle);
const dialogContent = document.createElement("div");
dialogContent.classList.add("center-dialog-content");
dialogContent.appendChild(contentNode);
newDialog.appendChild(dialogContent);
if (buttonBar !== null && buttonBar !== undefined) {
newDialog.appendChild(buttonBar);
}
newDialog.style.visibility = "visible";
centerDialogs.appendChild(newDialog);
}
// Shows an information dialog with a button that closes the dialog and
// removes it from the DOM.
function showInfoDialog(title, message, buttonText) {
const dialogId = "info_dialog";
closeDialog(dialogId);
const closeButton = createDialogButton(buttonText);
closeButton.addEventListener("click", () => {
closeDialog(dialogId);
});
const messageNode = document.createElement("span");
messageNode.innerText = message;
showDialog(
dialogId,
title,
messageNode,
createDialogButtonBar(closeButton),
);
}
function createDialogButton(text) {
const button = document.createElement("button");
button.innerText = text;
button.classList.add("dialog-button");
return button;
}
function createDialogButtonBar(...buttons) {
const buttonBar = document.createElement("div");
buttonBar.classList.add("button-bar");
buttons.forEach(buttonBar.appendChild);
return buttonBar;
}
function closeDialog(id) {
const dialog = document.getElementById(id);
if (dialog !== undefined && dialog !== null) {
const parent = dialog.parentElement;
if (parent !== undefined && parent !== null) {
parent.removeChild(dialog);
}
}
}
const helpDialogId = "help-dialog";
function showHelpDialog() {
closeDialog(helpDialogId);
const controlsLabel = document.createElement("b");
controlsLabel.innerText = '{{.Translation.Get "controls"}}';
const controlsTextOne = document.createElement("p");
controlsTextOne.innerText = '{{.Translation.Get "switch-tools-intro"}}:';
const controlsTextTwo = document.createElement("p");
controlsTextTwo.innerHTML =
'{{.Translation.Get "pencil"}}: <kbd>Q</kbd><br/>' +
'{{.Translation.Get "fill-bucket"}}: <kbd>W</kbd><br/>' +
'{{.Translation.Get "eraser"}}: <kbd>E</kbd><br/>';
const controlsTextThree = document.createElement("p");
controlsTextThree.innerHTML =
'{{printf (.Translation.Get "switch-pencil-sizes") "<kbd>1</kbd>" "<kbd>4</kbd>"}}';
const closeButton = createDialogButton('{{.Translation.Get "close"}}');
closeButton.addEventListener("click", () => {
closeDialog(helpDialogId);
});
const footer = document.createElement("div");
footer.className = "help-footer";
footer.innerHTML = `{{template "footer" . }}`;
const buttonBar = createDialogButtonBar(closeButton);
const dialogContent = document.createElement("div");
dialogContent.appendChild(controlsLabel);
dialogContent.appendChild(controlsTextOne);
dialogContent.appendChild(controlsTextTwo);
dialogContent.appendChild(controlsTextThree);
dialogContent.appendChild(footer);
showDialog(
helpDialogId,
'{{.Translation.Get "help"}}',
dialogContent,
buttonBar,
);
}
document
.getElementById("help-button")
.addEventListener("click", showHelpDialog);
function showKickDialog() {
hideMenu();
if (cachedPlayers && cachedPlayers) {
kickDialogPlayers.innerHTML = "";
cachedPlayers.forEach((player) => {
//Don't wanna allow kicking ourselves.
if (player.id !== ownID && player.connected) {
const playerKickEntry = document.createElement("button");
playerKickEntry.classList.add("kick-player-button");
playerKickEntry.classList.add("dialog-button");
playerKickEntry.onclick = () => onVotekickPlayer(player.id);
playerKickEntry.innerText = player.name;
kickDialogPlayers.appendChild(playerKickEntry);
}
});
kickDialog.style.visibility = "visible";
}
}
document
.getElementById("kick-button")
.addEventListener("click", showKickDialog);
function hideKickDialog() {
kickDialog.style.visibility = "hidden";
}
document
.getElementById("kick-close-button")
.addEventListener("click", hideKickDialog);
function showNameChangeDialog() {
hideMenu();
namechangeDialog.style.visibility = "visible";
namechangeField.focus();
}
document
.getElementById("name-change-button")
.addEventListener("click", showNameChangeDialog);
function hideNameChangeDialog() {
namechangeDialog.style.visibility = "hidden";
}
document
.getElementById("namechange-close-button")
.addEventListener("click", hideNameChangeDialog);
function changeName(name) {
//Avoid unnecessary traffic.
if (name !== ownName) {
socket.send(
JSON.stringify({
type: "name-change",
data: name,
}),
);
}
}
document
.getElementById("namechange-button-start-dialog")
.addEventListener("click", () => {
changeName(
document.getElementById("namechange-field-start-dialog").value,
);
});
document.getElementById("namechange-button").addEventListener("click", () => {
changeName(document.getElementById("namechange-field").value);
hideNameChangeDialog();
});
function setUsernameLocally(name) {
ownName = name;
namechangeFieldStartDialog.value = name;
namechangeField.value = name;
}
function toggleFullscreen() {
if (document.fullscreenElement !== null) {
document.exitFullscreen();
} else {
document.body.requestFullscreen();
}
}
document
.getElementById("toggle-fullscreen-button")
.addEventListener("click", toggleFullscreen);
function showLobbySettingsDialog() {
hideMenu();
lobbySettingsDialog.style.visibility = "visible";
}
lobbySettingsButton.addEventListener("click", showLobbySettingsDialog);
function hideLobbySettingsDialog() {
lobbySettingsDialog.style.visibility = "hidden";
}
document
.getElementById("lobby-settings-close-button")
.addEventListener("click", hideLobbySettingsDialog);
function saveLobbySettings() {
fetch(
`${rootPath}/v1/lobby?` +
new URLSearchParams({
drawing_time: document.getElementById(
"lobby-settings-drawing-time",
).value,
rounds: document.getElementById("lobby-settings-max-rounds")
.value,
public: document.getElementById("lobby-settings-public")
.checked,
max_players: document.getElementById(
"lobby-settings-max-players",
).value,
clients_per_ip_limit: document.getElementById(
"lobby-settings-clients-per-ip-limit",
).value,
custom_words_per_turn: document.getElementById(
"lobby-settings-custom-words-per-turn",
).value,
words_per_turn: document.getElementById(
"lobby-settings-words-per-turn",
).value,
}),
{
method: "PATCH",
},
).then((result) => {
if (result.status === 200) {
hideLobbySettingsDialog();
} else {
result.text().then((bodyText) => {
alert(
"Error saving lobby settings: \n\n - " +
bodyText.replace(";", "\n - "),
);
});
}
});
}
document
.getElementById("lobby-settings-save-button")
.addEventListener("click", saveLobbySettings);
function toggleSound() {
sound = !sound;
localStorage.setItem("sound", sound.toString());
updateSoundIcon();
}
document
.getElementById("toggle-sound-button")
.addEventListener("click", toggleSound);
function updateSoundIcon() {
if (sound) {
soundToggleLabel.src = `{{.RootPath}}/resources/{{.WithCacheBust "sound.svg"}}`;
} else {
soundToggleLabel.src = `{{.RootPath}}/resources/{{.WithCacheBust "no-sound.svg"}}`;
}
}
function togglePenPressure() {
penPressure = !penPressure;
localStorage.setItem("penPressure", penPressure.toString());
updateTogglePenIcon();
}
document
.getElementById("toggle-pen-pressure-button")
.addEventListener("click", togglePenPressure);
function updateTogglePenIcon() {
if (penPressure) {
penToggleLabel.src = `{{.RootPath}}/resources/{{.WithCacheBust "pen.svg"}}`;
} else {
penToggleLabel.src = `{{.RootPath}}/resources/{{.WithCacheBust "no-pen.svg"}}`;
}
}
//The drawing board has a base size. This base size results in a certain ratio
//that the actual canvas has to be resized accordingly too. This is needed
//since not every client has the same screensize.
const baseWidth = 1600;
const baseHeight = 900;
const boardRatio = baseWidth / baseHeight;
// Moving this here to extract the context after resizing
const context = drawingBoard.getContext("2d", { alpha: false });
// One might one wonder what the fuck is going here. I'll enlighten you!
// The data you put into a canvas, might not necessarily be what comes out
// of it again. Some browser (*cough* firefox *cough*) seem to put little
// off by one / two errors into the data, when reading it back out.
// Apparently this helps against some type of fingerprinting. In order to
// combat this, we do not use the canvas as a source of truth, but
// permanently hold a virtual canvas buffer that we can operate on when
// filling or drawing.
let imageData;
function scaleUpFactor() {
return baseWidth / drawingBoard.clientWidth;
}
// Will convert the value to the server coordinate space.
// The canvas locally can be bigger or smaller. Depending on the base
// values and the local values, we'll either have a value slightly
// higher or lower than 1.0. Since we draw on a virtual canvas, we have
// to use the server coordinate space, which then gets scaled by the
// canvas API of the browser, as we have a different clientWidth than
// width and clientHeight than height.
function convertToServerCoordinate(value) {
return Math.round(parseFloat(scaleUpFactor() * value));
}
const pen = 0;
const rubber = 1;
const fillBucket = 2;
let allowDrawing = false;
let spectating = false;
let spectateRequested = false;
//Initially, we require some values to avoid running into nullpointers
//or undefined errors. The specific values don't really matter.
let localTool = pen;
let localLineWidth = 8;
//Those are not scaled for now, as the whole toolbar would then have to incorrectly size up and down.
const sizeButton8 = document.getElementById("size-8-button");
const sizeButton16 = document.getElementById("size-16-button");
const sizeButton24 = document.getElementById("size-24-button");
const sizeButton32 = document.getElementById("size-32-button");
const sizeButtons = document.getElementById("size-buttons");
const toolButtonPen = document.getElementById("tool-type-pencil");
const toolButtonRubber = document.getElementById("tool-type-rubber");
const toolButtonFill = document.getElementById("tool-type-fill");
if (sizeButton8.checked) {
setLineWidthNoUpdate(8);
} else if (sizeButton16.checked) {
setLineWidthNoUpdate(16);
} else if (sizeButton24.checked) {
setLineWidthNoUpdate(24);
} else if (sizeButton32.checked) {
setLineWidthNoUpdate(32);
}
if (toolButtonPen.checked) {
chooseToolNoUpdate(pen);
} else if (toolButtonFill.checked) {
chooseToolNoUpdate(fillBucket);
} else if (toolButtonRubber.checked) {
chooseToolNoUpdate(rubber);
}
let localColor, localColorIndex;
function setColor(index) {
setColorNoUpdate(index);
// If we select a new color, we assume we don't want to use the
// rubber anymore and automatically switch to the pen.
if (localTool === rubber) {
// Clicking the button programmatically won't trigger its
toolButtonPen.click();
// updateDrawingStateUI is implicit
chooseTool(pen);
} else {
updateDrawingStateUI();
}
}
const firstColorButtonRow = document.getElementById("first-color-button-row");
const secondColorButtonRow = document.getElementById("second-color-button-row");
for (let i = 0; i < firstColorButtonRow.children.length; i++) {
const _setColor = () => setColor(i);
firstColorButtonRow.children[i].addEventListener("mousedown", _setColor);
firstColorButtonRow.children[i].addEventListener("click", _setColor);
}
for (let i = 0; i < secondColorButtonRow.children.length; i++) {
const _setColor = () => setColor(i + 13);
secondColorButtonRow.children[i].addEventListener("mousedown", _setColor);
secondColorButtonRow.children[i].addEventListener("click", _setColor);
}
function setColorNoUpdate(index) {
localColorIndex = index;
localColor = indexToRgbColor(index);
sessionStorage.setItem("local_color", JSON.stringify(index));
}
setColorNoUpdate(
JSON.parse(sessionStorage.getItem("local_color")) ?? 13 /* black*/,
);
updateDrawingStateUI();
function setLineWidth(value) {
setLineWidthNoUpdate(value);
updateDrawingStateUI();
}
sizeButton8.addEventListener("change", () => setLineWidth(8));
document
.getElementById("size-8-button-wrapper")
.addEventListener("mouseup", sizeButton8.click);
document
.getElementById("size-8-button-wrapper")
.addEventListener("mousedown", sizeButton8.click);
sizeButton16.addEventListener("change", () => setLineWidth(16));
document
.getElementById("size-16-button-wrapper")
.addEventListener("mouseup", sizeButton16.click);
document
.getElementById("size-16-button-wrapper")
.addEventListener("mousedown", sizeButton16.click);
sizeButton24.addEventListener("change", () => setLineWidth(24));
document
.getElementById("size-24-button-wrapper")
.addEventListener("mouseup", sizeButton24.click);
document
.getElementById("size-24-button-wrapper")
.addEventListener("mousedown", sizeButton24.click);
sizeButton32.addEventListener("change", () => setLineWidth(32));
document
.getElementById("size-32-button-wrapper")
.addEventListener("mouseup", sizeButton32.click);
document
.getElementById("size-32-button-wrapper")
.addEventListener("mousedown", sizeButton32.click);
function setLineWidthNoUpdate(value) {
localLineWidth = value;
}
function chooseTool(value) {
chooseToolNoUpdate(value);
updateDrawingStateUI();
}
toolButtonFill.addEventListener("change", () => chooseTool(fillBucket));
toolButtonPen.addEventListener("change", () => chooseTool(pen));
toolButtonRubber.addEventListener("change", () => chooseTool(rubber));
document
.getElementById("tool-type-fill-wrapper")
.addEventListener("mouseup", toolButtonFill.click);
document
.getElementById("tool-type-pencil-wrapper")
.addEventListener("mouseup", toolButtonPen.click);
document
.getElementById("tool-type-rubber-wrapper")
.addEventListener("mouseup", toolButtonRubber.click);
document
.getElementById("tool-type-fill-wrapper")
.addEventListener("mousedown", toolButtonFill.click);
document
.getElementById("tool-type-pencil-wrapper")
.addEventListener("mousedown", toolButtonPen.click);
document
.getElementById("tool-type-rubber-wrapper")
.addEventListener("mousedown", toolButtonRubber.click);
function chooseToolNoUpdate(value) {
if (value === pen || value === rubber || value === fillBucket) {
localTool = value;
} else {
//If this ends up with an invalid value, we use the pencil.
localTool = pen;
}
}
function rgbColorObjectToHexString(color) {
return (
"#" +
numberTo16BitHexadecimal(color.r) +
numberTo16BitHexadecimal(color.g) +
numberTo16BitHexadecimal(color.b)
);
}
function numberTo16BitHexadecimal(number) {
return Number(number).toString(16).padStart(2, "0");
}
const rubberColor = { r: 255, g: 255, b: 255 };
function updateDrawingStateUI() {
// Color all buttons, so the player always has a hint as to what the
// active color is, since the cursor might not always be visible.
sizeButtons.style.setProperty(
"--dot-color",
rgbColorObjectToHexString(localColor),
);
updateCursor();
}
function updateCursor() {
if (allowDrawing) {
if (localTool === rubber) {
setCircleCursor(rubberColor, localLineWidth);
} else if (localTool === fillBucket) {
const outerColor = getComplementaryCursorColor(localColor);
drawingBoard.style.cursor =
`url('data:image/svg+xml;utf8,` +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="32" width="32">` +
generateSVGCircle(8, localColor, outerColor) +
//This has been taken from fill.svg
`
<svg viewBox="0 0 64 64" x="8" y="8" height="24" width="24">
<path
d="m 59.575359,58.158246 c 0,1.701889 -1.542545,3.094345 -3.427877,3.094345 H 8.1572059 c -1.8853322,0 -3.4278772,-1.392456 -3.4278772,-3.094345 V 5.5543863 c 0,-1.7018892 1.542545,-3.0943445 3.4278772,-3.0943445 H 56.147482 c 1.885332,0 3.427877,1.3924553 3.427877,3.0943445 z"
id="path8"
style="stroke-width:1.62842;fill:#b3b3b3" />
<path
d="M 56.147482,2.4600418 H 8.1572059 c -1.8853322,0 -3.4278772,1.3152251 -3.4278772,2.9227219 V 14.15093 c 0,1.607497 0,0 0,0 l 26.5660453,2.922722 c 0.685576,0 2.570908,0.584545 2.570908,1.89977 0,0 0,1.899769 0,2.484313 0,1.169089 1.199758,2.192042 2.570908,2.192042 1.371151,0 2.570908,-1.022953 2.570908,-2.192042 0,-1.169089 1.199756,-2.192041 2.570908,-2.192041 1.37115,0 2.570907,1.022952 2.570907,2.192041 v 19.728374 c 0,1.169089 1.199757,2.192042 2.570908,2.192042 1.37115,0 2.570907,-1.022953 2.570907,-2.192042 V 25.841818 c 0,-1.169088 1.199758,-2.192041 2.570908,-2.192041 1.371151,0 2.570908,1.022953 2.570908,2.192041 v 3.653404 c 0,1.169088 1.199756,2.192041 2.570907,2.192041 1.371151,0 2.570908,-1.022953 2.570908,-2.192041 V 5.3827637 c 0,-1.6074968 -1.542545,-2.9227219 -3.427877,-2.9227219 z"
id="path12"
style="stroke-width:1.58262;fill:#C75C5C" />
<path
d="m 60.432329,6.1134441 c 0,13.2983859 -12.683145,24.1124579 -28.279986,24.1124579 -15.596839,0 -28.2799836,-10.814072 -28.2799836,-24.1124579"
id="path18"
style="fill:none;stroke:#4F5D73;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10" />
</svg>
</svg>`,
) +
`') 4 4, auto`;
} else {
setCircleCursor(localColor, localLineWidth);
}
} else {
drawingBoard.style.cursor = "not-allowed";
}
}
function getComplementaryCursorColor(innerColor) {
const hsp = Math.sqrt(
0.299 * (innerColor.r * innerColor.r) +
0.587 * (innerColor.g * innerColor.g) +
0.114 * (innerColor.b * innerColor.b),
);
if (hsp > 127.5) {
return { r: 0, g: 0, b: 0 };
}
return { r: 255, g: 255, b: 255 };
}
function setCircleCursor(innerColor, size) {
const outerColor = getComplementaryCursorColor(innerColor);
const circleSize = size;
drawingBoard.style.cursor =
`url('data:image/svg+xml;utf8,` +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="32">` +
generateSVGCircle(circleSize, innerColor, outerColor) +
`</svg>')`,
) +
` ` +
circleSize / 2 +
` ` +
circleSize / 2 +
`, auto`;
}
function generateSVGCircle(circleSize, innerColor, outerColor) {
const circleRadius = circleSize / 2;
const innerColorCSS =
"rgb(" + innerColor.r + "," + innerColor.g + "," + innerColor.b + ")";
const outerColorCSS =
"rgb(" + outerColor.r + "," + outerColor.g + "," + outerColor.b + ")";
return (
`<circle cx="` +
circleRadius +
`" cy="` +
circleRadius +
`" r="` +
circleRadius +
`" style="fill: ` +
innerColorCSS +
`; stroke: ` +
outerColorCSS +
`;"/>`
);
}
function toggleSpectate() {
socket.send(
JSON.stringify({
type: "toggle-spectate",
}),
);
}
document
.getElementById("toggle-spectate-button")
.addEventListener("click", toggleSpectate);
function setSpectateMode(requestedValue, spectatingValue) {
const modeUnchanged = spectatingValue === spectating;
const requestUnchanged = requestedValue === spectateRequested;
if (modeUnchanged && requestUnchanged) {
return;
}
if (spectateRequested && !requestedValue && modeUnchanged) {
showInfoDialog(
`{{.Translation.Get "spectation-request-cancelled-title"}}`,
`{{.Translation.Get "spectation-request-cancelled-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
} else if (spectateRequested && !requestedValue && modeUnchanged) {
showInfoDialog(
`{{.Translation.Get "participation-request-cancelled-title"}}`,
`{{.Translation.Get "participation-request-cancelled-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
} else if (!spectateRequested && requestedValue && !spectatingValue) {
showInfoDialog(
`{{.Translation.Get "spectation-requested-title"}}`,
`{{.Translation.Get "spectation-requested-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
} else if (!spectateRequested && requestedValue && spectatingValue) {
showInfoDialog(
`{{.Translation.Get "participation-requested-title"}}`,
`{{.Translation.Get "participation-requested-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
} else if (spectatingValue && !spectating) {
showInfoDialog(
`{{.Translation.Get "now-spectating-title"}}`,
`{{.Translation.Get "now-spectating-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
} else if (!spectatingValue && spectating) {
showInfoDialog(
`{{.Translation.Get "now-participating-title"}}`,
`{{.Translation.Get "now-participating-text"}}`,
`{{.Translation.Get "confirm"}}`,
);
}
spectateRequested = requestedValue;
spectating = spectatingValue;
}
function toggleReadiness() {
socket.send(
JSON.stringify({
type: "toggle-readiness",
}),
);
}
document
.getElementById("ready-state-start")
.addEventListener("change", toggleReadiness);
document
.getElementById("ready-state-game-over")
.addEventListener("change", toggleReadiness);
function forceStartGame() {
socket.send(
JSON.stringify({
type: "start",
}),
);
}
forceStartButton.addEventListener("click", forceStartGame);
forceRestartButton.addEventListener("click", forceStartGame);
function clearCanvasAndSendEvent() {
if (allowDrawing) {
//Avoid unnecessary traffic back to us and handle the clear directly.
clear(context);
socket.send(
JSON.stringify({
type: "clear-drawing-board",
}),
);
}
}
document
.getElementById("clear-canvas-button")
.addEventListener("click", clearCanvasAndSendEvent);
function undoAndSendEvent() {
if (allowDrawing) {
socket.send(
JSON.stringify({
type: "undo",
}),
);
}
}
document
.getElementById("undo-button")
.addEventListener("click", undoAndSendEvent);
//Used to restore the last message on arrow up.
let lastMessage = "";
const encoder = new TextEncoder();
function sendMessage(event) {
if (event.key !== "Enter") {
return;
}
if (!messageInput.value) {
return;
}
// While the backend already checks for message length, we want to
// prevent the loss of input and omit the event / clear here.
if (encoder.encode(messageInput.value).length > 10000) {
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
'{{.Translation.Get "message-too-long"}}',
);
//We keep the messageInput content, since it could've been
//something important and we don't want the user having to
//rewrite it. Instead they can send it via some other means
//or shorten it a bit.
return;
}
socket.send(
JSON.stringify({
type: "message",
data: messageInput.value,
}),
);
lastMessage = messageInput.value;
messageInput.value = "";
}
messageInput.addEventListener("keypress", sendMessage);
messageInput.addEventListener("keydown", function (event) {
if (event.key === "ArrowUp" && messageInput.value.length === 0) {
messageInput.value = lastMessage;
const length = lastMessage.length;
// Postpone selection change onto next event queue loop iteration, as
// nothing will happen otherwise.
setTimeout(() => {
// length+1 is necessary, as the selection wont change if start and
// end are the same,
messageInput.setSelectionRange(length + 1, length);
}, 0);
}
});
function setAllowDrawing(value) {
allowDrawing = value;
updateDrawingStateUI();
if (allowDrawing) {
document.getElementById("toolbox").style.display = "flex";
} else {
document.getElementById("toolbox").style.display = "none";
}
}
function chooseWord(index) {
socket.send(
JSON.stringify({
type: "choose-word",
data: index,
}),
);
setAllowDrawing(true);
wordDialog.style.visibility = "hidden";
}
function onVotekickPlayer(playerId) {
socket.send(
JSON.stringify({
type: "kick-vote",
data: playerId,
}),
);
hideKickDialog();
}
//This automatically scrolls down the chat on arrivals of new messages
new MutationObserver(
() => (messageContainer.scrollTop = messageContainer.scrollHeight),
).observe(messageContainer, {
attributes: false,
childList: true,
subtree: false,
});
let ownID, ownerID, ownName, drawerID, drawerName;
let round = 0;
let rounds = 0;
let roundEndTime = 0;
let gameState = "unstarted";
let drawingTimeSetting = "∞";
const handleEvent = (parsed) => {
if (parsed.type === "ready") {
handleReadyEvent(parsed.data);
} else if (parsed.type === "game-over") {
let ready = parsed.data;
if (parsed.data.roundEndReason === "drawer_disconnected") {
appendMessage(
"system-message",
null,
`{{.Translation.Get "drawer-disconnected"}}`,
);
} else if (parsed.data.roundEndReason === "guessers_disconnected") {
appendMessage(
"system-message",
null,
`{{.Translation.Get "guessers-disconnected"}}`,
);
} else {
showRoundEndMessage(ready.previousWord);
}
handleReadyEvent(ready);
} else if (parsed.type === "update-players") {
applyPlayers(parsed.data);
} else if (parsed.type === "name-change") {
const player = getCachedPlayer(parsed.data.playerId);
if (player !== null) {
player.name = parsed.data.playerName;
}
const playernameSpan = document.getElementById(
"playername-" + parsed.data.playerId,
);
if (playernameSpan !== null) {
playernameSpan.innerText = parsed.data.playerName;
}
if (parsed.data.playerId === ownID) {
setUsernameLocally(parsed.data.playerName);
}
if (parsed.data.playerId === drawerID) {
waitChooseDrawerSpan.innerText = parsed.data.playerName;
}
} else if (parsed.type === "correct-guess") {
playWav('{{.RootPath}}/resources/{{.WithCacheBust "plop.wav"}}');
if (parsed.data === ownID) {
appendMessage(
"correct-guess-message",
null,
`{{.Translation.Get "correct-guess"}}`,
);
} else {
const player = getCachedPlayer(parsed.data);
if (player !== null) {
appendMessage(
"correct-guess-message-other-player",
null,
`{{.Translation.Get "correct-guess-other-player"}}`.format(
player.name,
),
);
}
}
} else if (parsed.type === "close-guess") {
appendMessage(
"close-guess-message",
null,
`{{.Translation.Get "close-guess"}}`.format(parsed.data),
);
} else if (parsed.type === "update-wordhint") {
wordDialog.style.visibility = "hidden";
waitChooseDialog.style.visibility = "hidden";
applyWordHints(parsed.data);
// We don't do this in applyWordHints because that's called in all kinds of places
if (parsed.data.some((hint) => hint.character)) {
var hints = parsed.data
.map((hint) => {
if (hint.character) {
var char = String.fromCharCode(hint.character);
if (char === " " || hint.revealed) {
return char;
}
}
return "_";
})
.join(" ");
appendMessage(
["system-message", "hint-chat-message"],
'{{.Translation.Get "system"}}',
'{{.Translation.Get "word-hint-revealed"}}\n' + hints,
{ dir: wordContainer.getAttribute("dir") },
);
}
} else if (parsed.type === "message") {
appendMessage(null, parsed.data.author, parsed.data.content);
} else if (parsed.type === "system-message") {
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
parsed.data,
);
} else if (parsed.type === "non-guessing-player-message") {
appendMessage(
"non-guessing-player-message",
parsed.data.author,
parsed.data.content,
);
} else if (parsed.type === "line") {
drawLine(
context,
imageData,
parsed.data.x,
parsed.data.y,
parsed.data.x2,
parsed.data.y2,
indexToRgbColor(parsed.data.color),
parsed.data.width,
);
} else if (parsed.type === "fill") {
if (
floodfillUint8ClampedArray(
imageData.data,
parsed.data.x,
parsed.data.y,
indexToRgbColor(parsed.data.color),
imageData.width,
imageData.height,
)
) {
context.putImageData(imageData, 0, 0);
}
} else if (parsed.type === "clear-drawing-board") {
clear(context);
} else if (parsed.type === "word-chosen") {
wordDialog.style.visibility = "hidden";
waitChooseDialog.style.visibility = "hidden";
setRoundTimeLeft(parsed.data.timeLeft);
applyWordHints(parsed.data.hints);
setAllowDrawing(drawerID === ownID);
} else if (parsed.type === "next-turn") {
if (gameState === "ongoing") {
if (parsed.data.roundEndReason === "drawer_disconnected") {
appendMessage(
"system-message",
null,
`{{.Translation.Get "drawer-disconnected"}}`,
);
} else if (parsed.data.roundEndReason === "guessers_disconnected") {
appendMessage(
"system-message",
null,
`{{.Translation.Get "guessers-disconnected"}}`,
);
} else {
showRoundEndMessage(parsed.data.previousWord);
}
} else {
//First turn, the game starts
gameState = "ongoing";
}
//As soon as a turn starts, the round should be ongoing, so we make
//sure that all types of dialogs, that indicate the game isn't
//ongoing, are not visible anymore.
startDialog.style.visibility = "hidden";
forceRestartButton.style.display = "none";
gameOverDialog.style.visibility = "hidden";
//If a player doesn't choose, the dialog will still be up.
wordDialog.style.visibility = "hidden";
playWav('{{.RootPath}}/resources/{{.WithCacheBust "end-turn.wav"}}');
clear(context);
round = parsed.data.round;
updateRoundsDisplay();
setRoundTimeLeft(parsed.data.choiceTimeLeft);
applyPlayers(parsed.data.players);
set_dummy_word_hints();
//Even though we always hide the dialog in the "your-turn"
//event handling, it will be shortly visible if we it here.
if (drawerID !== ownID) {
//Show additional dialog, that another user (drawer) is choosing a word
waitChooseDrawerSpan.innerText = drawerName;
waitChooseDialog.style.visibility = "visible";
}
setAllowDrawing(false);
} else if (parsed.type === "your-turn") {
playWav('{{.RootPath}}/resources/{{.WithCacheBust "your-turn.wav"}}');
//This dialog could potentially stay visible from last
//turn, in case nobody has chosen a word.
waitChooseDialog.style.visibility = "hidden";
promptWords(parsed.data);
} else if (parsed.type === "drawing") {
applyDrawData(parsed.data);
} else if (parsed.type === "kick-vote") {
if (
parsed.data.playerId === ownID &&
parsed.data.voteCount >= parsed.data.requiredVoteCount
) {
alert('{{.Translation.Get "self-kicked"}}');
document.location.href = "{{.RootPath}}/";
} else {
let kickMessage = '{{.Translation.Get "kick-vote"}}'.format(
parsed.data.voteCount,
parsed.data.requiredVoteCount,
parsed.data.playerName,
);
if (parsed.data.voteCount >= parsed.data.requiredVoteCount) {
kickMessage += ' {{.Translation.Get "player-kicked"}}';
}
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
kickMessage,
);
}
} else if (parsed.type === "owner-change") {
ownerID = parsed.data.playerId;
updateButtonVisibilities();
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
'{{.Translation.Get "owner-change"}}'.format(
parsed.data.playerName,
),
);
} else if (parsed.type === "drawer-kicked") {
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
'{{.Translation.Get "drawer-kicked"}}',
);
} else if (parsed.type === "lobby-settings-changed") {
rounds = parsed.data.rounds;
updateRoundsDisplay();
updateButtonVisibilities();
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
'{{.Translation.Get "lobby-settings-changed"}}\n\n' +
'{{.Translation.Get "drawing-time-setting"}}: ' +
parsed.data.drawingTime +
"\n" +
'{{.Translation.Get "rounds-setting"}}: ' +
parsed.data.rounds +
"\n" +
'{{.Translation.Get "public-lobby-setting"}}: ' +
parsed.data.public +
"\n" +
'{{.Translation.Get "max-players-setting"}}: ' +
parsed.data.maxPlayers +
"\n" +
'{{.Translation.Get "custom-words-per-turn-setting"}}: ' +
parsed.data.customWordsPerTurn +
"\n" +
'{{.Translation.Get "players-per-ip-limit-setting"}}: ' +
parsed.data.clientsPerIpLimit +
"\n" +
'{{.Translation.Get "words-per-turn-setting"}}: ' +
parsed.data.wordsPerTurn,
);
} else if (parsed.type === "shutdown") {
socket.onclose = null;
socket.close();
showDialog(
"shutdown-info",
'{{.Translation.Get "server-shutting-down-title"}}',
document.createTextNode(
'{{.Translation.Get "server-shutting-down-text"}}',
),
);
}
};
function showRoundEndMessage(previousWord) {
if (previousWord === "") {
appendMessage(
"system-message",
null,
`{{.Translation.Get "round-over"}}`,
);
} else {
appendMessage(
"system-message",
null,
`{{.Translation.Get "round-over-no-word"}}`.format(previousWord),
);
}
}
function getCachedPlayer(playerID) {
if (!cachedPlayers) {
return null;
}
for (let i = 0; i < cachedPlayers.length; i++) {
const player = cachedPlayers[i];
if (player.id === playerID) {
return player;
}
}
return null;
}
//In the initial implementation we used a timestamp to know when
//the round will end. The problem with that approach was that the
//clock on client and server was often not in sync. The second
//approach was to instead send milliseconds left and keep counting
//them down each 500 milliseconds. The problem with this approach, was
//that there could potentially be timing mistakes while counting down.
//What we do instead is use our local date, add the timeLeft to it and
//repeatdly recaculate the timeLeft using the roundEndTime and the
//current time. This way we won't have any calculation errors.
//
//FIXME The only leftover issue is that ping isn't taken into
//account, however, that's no biggie for now.
function setRoundTimeLeft(timeLeftMs) {
roundEndTime = Date.now() + timeLeftMs;
}
const handleReadyEvent = (ready) => {
ownerID = ready.ownerId;
ownID = ready.playerId;
setRoundTimeLeft(ready.timeLeft);
setUsernameLocally(ready.playerName);
setAllowDrawing(ready.allowDrawing);
round = ready.round;
rounds = ready.rounds;
gameState = ready.gameState;
drawingTimeSetting = ready.drawingTimeSetting;
updateRoundsDisplay();
updateButtonVisibilities();
if (ready.players && ready.players.length) {
applyPlayers(ready.players);
}
if (ready.currentDrawing && ready.currentDrawing.length) {
applyDrawData(ready.currentDrawing);
}
if (ready.wordHints && ready.wordHints.length) {
applyWordHints(ready.wordHints);
} else {
set_dummy_word_hints();
}
if (ready.gameState === "unstarted") {
startDialog.style.visibility = "visible";
if (ownerID === ownID) {
forceStartButton.style.display = "block";
} else {
forceStartButton.style.display = "none";
}
} else if (ready.gameState === "gameOver") {
gameOverDialog.style.visibility = "visible";
if (ownerID === ownID) {
forceRestartButton.style.display = "block";
}
gameOverScoreboard.innerHTML = "";
//Copying array so we can sort.
const players = cachedPlayers.slice();
players.sort((a, b) => {
return a.rank - b.rank;
});
//These two are required for displaying the "game over / win / tie" message.
let countOfRankOnePlayers = 0;
let selfPlayer;
for (let i = 0; i < players.length; i++) {
const player = players[i];
if (!player.connected || player.state === "spectating") {
continue;
}
if (player.rank === 1) {
countOfRankOnePlayers++;
}
if (player.id === ownID) {
selfPlayer = player;
}
// We only display the first 5 players on the scoreboard.
if (player.rank <= 5) {
const newScoreboardEntry = document.createElement("div");
newScoreboardEntry.classList.add("gameover-scoreboard-entry");
if (player.id === ownID) {
newScoreboardEntry.classList.add(
"gameover-scoreboard-entry-self",
);
}
const scoreboardRankDiv = document.createElement("div");
scoreboardRankDiv.classList.add("gameover-scoreboard-rank");
scoreboardRankDiv.innerText = player.rank;
newScoreboardEntry.appendChild(scoreboardRankDiv);
const scoreboardNameDiv = document.createElement("div");
scoreboardNameDiv.classList.add("gameover-scoreboard-name");
scoreboardNameDiv.innerText = player.name;
newScoreboardEntry.appendChild(scoreboardNameDiv);
const scoreboardScoreSpan = document.createElement("span");
scoreboardScoreSpan.classList.add("gameover-scoreboard-score");
scoreboardScoreSpan.innerText = player.score;
newScoreboardEntry.appendChild(scoreboardScoreSpan);
gameOverScoreboard.appendChild(newScoreboardEntry);
}
}
if (selfPlayer.rank === 1) {
if (countOfRankOnePlayers >= 2) {
gameOverDialogTitle.innerText = `{{.Translation.Get "game-over-tie"}}`;
} else {
gameOverDialogTitle.innerText = `{{.Translation.Get "game-over-win"}}`;
}
} else {
gameOverDialogTitle.innerText =
`{{.Translation.Get "game-over"}}`.format(
selfPlayer.rank,
selfPlayer.score,
);
}
} else if (ready.gameState === "ongoing") {
// Lack of wordHints implies that word has been chosen yet.
if (!ready.wordHints && drawerID !== ownID) {
waitChooseDrawerSpan.innerText = drawerName;
waitChooseDialog.style.visibility = "visible";
}
}
};
function updateButtonVisibilities() {
if (ownerID === ownID) {
lobbySettingsButton.style.display = "flex";
} else {
lobbySettingsButton.style.display = "none";
}
}
function promptWords(data) {
wordPreSelected.textContent = data.words[data.preSelectedWord];
wordButtonContainer.replaceChildren(
...data.words.map((word, index) => {
const button = createDialogButton(word);
button.onclick = () => {
chooseWord(index);
};
return button;
}),
);
wordDialog.style.visibility = "visible";
}
function playWav(file) {
if (sound) {
const audio = new Audio(file);
audio.type = "audio/wav";
audio.play();
}
}
window.setInterval(() => {
if (gameState === "ongoing") {
const msLeft = roundEndTime - Date.now();
const secondsLeft = Math.max(0, Math.floor(msLeft / 1000));
timeLeftValue.innerText = "" + secondsLeft;
} else {
timeLeftValue.innerText = "∞";
}
}, 500);
//appendMessage adds a new message to the message container. If the
//message amount is too high, we cut off a part of the messages to
//prevent lagging and useless memory usage.
function appendMessage(styleClass, author, message, attrs) {
if (messageContainer.childElementCount >= 100) {
messageContainer.removeChild(messageContainer.firstChild);
}
const newMessageDiv = document.createElement("div");
newMessageDiv.classList.add("message");
if (isString(styleClass)) {
styleClass = [styleClass];
}
for (const cls of styleClass) {
newMessageDiv.classList.add(cls);
}
if (author !== null && author !== "") {
const authorNameSpan = document.createElement("span");
authorNameSpan.classList.add("chat-name");
authorNameSpan.innerText = author;
newMessageDiv.appendChild(authorNameSpan);
}
const messageSpan = document.createElement("span");
messageSpan.classList.add("message-content");
messageSpan.innerText = message;
newMessageDiv.appendChild(messageSpan);
if (attrs !== null && attrs !== "") {
if (isObject(attrs)) {
for (const [attrKey, attrValue] of Object.entries(attrs)) {
messageSpan.setAttribute(attrKey, attrValue);
}
}
}
messageContainer.appendChild(newMessageDiv);
}
let cachedPlayers;
//applyPlayers takes the players passed, assigns them to cachedPlayers,
//refreshes the scoreboard and updates the drawerID and drawerName variables.
function applyPlayers(players) {
const matchOngoing = gameState === "ongoing";
if (!matchOngoing) {
let readyPlayers = 0;
let readyPlayersRequired = 0;
players.forEach((player) => {
if (!player.connected || player.state === "spectating") {
return;
}
readyPlayersRequired = readyPlayersRequired + 1;
if (player.state === "ready") {
readyPlayers = readyPlayers + 1;
}
if (player.id === ownID) {
document.getElementById("ready-state-start").checked =
player.state === "ready";
document.getElementById("ready-state-game-over").checked =
player.state === "ready";
}
});
const readyCounts = document.getElementsByClassName("ready-count");
const reaadyNeededs = document.getElementsByClassName("ready-needed");
Array.from(readyCounts).forEach((element) => {
element.innerText = readyPlayers.toString();
});
Array.from(reaadyNeededs).forEach((element) => {
element.innerText = readyPlayersRequired.toString();
});
}
playerContainer.innerHTML = "";
players.forEach((player) => {
// Makes sure that the "is choosing" a word dialog doesn't show
// "undefined" as the player name. Can happen, if the player
// disconnects after being assigned the drawer.
if (matchOngoing && player.state === "drawing") {
drawerID = player.id;
drawerName = player.name;
}
//We don't wanna show the disconnected players.
if (!player.connected) {
return;
}
if (player.id === ownID) {
setSpectateMode(
player.spectateToggleRequested,
player.state === "spectating",
);
}
const oldPlayer = getCachedPlayer(player.id);
if (
oldPlayer &&
oldPlayer.state === "spectating" &&
player.state !== "spectating"
) {
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
`${player.name} is now participating`,
);
} else if (
oldPlayer &&
oldPlayer.state !== "spectating" &&
player.state === "spectating"
) {
appendMessage(
"system-message",
'{{.Translation.Get "system"}}',
`${player.name} is now spectating`,
);
}
if (player.state === "spectating") {
return;
}
const playerDiv = document.createElement("div");
playerDiv.classList.add("player");
const scoreAndStatusDiv = document.createElement("div");
scoreAndStatusDiv.classList.add("score-and-status");
playerDiv.appendChild(scoreAndStatusDiv);
const playerscoreDiv = document.createElement("div");
playerscoreDiv.classList.add("playerscore-group");
scoreAndStatusDiv.appendChild(playerscoreDiv);
if (matchOngoing) {
if (player.state === "standby") {
playerDiv.classList.add("player-done");
} else if (player.state === "drawing") {
const playerStateImage = createPlayerStateImageNode(
`{{.RootPath}}/resources/{{.WithCacheBust "pencil.svg"}}`,
);
playerStateImage.style.transform = "scaleX(-1)";
scoreAndStatusDiv.appendChild(playerStateImage);
} else if (player.state === "standby") {
const playerStateImage = createPlayerStateImageNode(
`{{.RootPath}}/resources/{{.WithCacheBust "checkmark.svg"}}`,
);
scoreAndStatusDiv.appendChild(playerStateImage);
}
} else {
if (player.state === "ready") {
playerDiv.classList.add("player-ready");
}
}
const rankSpan = document.createElement("span");
rankSpan.classList.add("rank");
rankSpan.innerText = player.rank;
playerDiv.appendChild(rankSpan);
const playernameSpan = document.createElement("span");
playernameSpan.classList.add("playername");
playernameSpan.innerText = player.name;
playernameSpan.id = "playername-" + player.id;
if (player.id === ownID) {
playernameSpan.classList.add("playername-self");
}
playerDiv.appendChild(playernameSpan);
const playerscoreSpan = document.createElement("span");
playerscoreSpan.classList.add("playerscore");
playerscoreSpan.innerText = player.score;
playerscoreDiv.appendChild(playerscoreSpan);
const lastPlayerscoreSpan = document.createElement("span");
lastPlayerscoreSpan.classList.add("last-turn-score");
lastPlayerscoreSpan.innerText =
'{{.Translation.Get "last-turn"}}'.format(player.lastScore);
playerscoreDiv.appendChild(lastPlayerscoreSpan);
playerContainer.appendChild(playerDiv);
});
// We do this at the end, so we can access the old values while
// iterating over the new ones
cachedPlayers = players;
}
function createPlayerStateImageNode(path) {
const playerStateImage = document.createElement("img");
playerStateImage.style.width = "1rem";
playerStateImage.style.height = "1rem";
playerStateImage.src = path;
return playerStateImage;
}
function updateRoundsDisplay() {
roundSpan.innerText = round;
maxRoundSpan.innerText = rounds;
}
const applyWordHints = (wordHints, dummy) => {
const isDrawer = drawerID === ownID;
let wordLengths = [];
let count = 0;
wordContainer.replaceChildren(
...wordHints.map((hint, index) => {
const hintSpan = document.createElement("span");
hintSpan.classList.add("hint");
if (dummy) {
hintSpan.style.visibility = "hidden";
}
if (hint.character === 0) {
hintSpan.classList.add("hint-underline");
hintSpan.innerHTML = " ";
} else {
if (hint.underline) {
hintSpan.classList.add("hint-underline");
}
hintSpan.innerText = String.fromCharCode(hint.character);
}
// space
if (hint.character === 32) {
wordLengths.push(count);
count = 0;
} else if (index === wordHints.length - 1) {
count += 1;
wordLengths.push(count);
} else {
count += 1;
}
if (hint.revealed && isDrawer) {
hintSpan.classList.add("hint-revealed");
}
return hintSpan;
}),
);
const lengthHint = document.createElement("sub");
lengthHint.classList.add("word-length-hint");
if (dummy) {
lengthHint.style.visibility = "hidden";
}
lengthHint.setAttribute("dir", wordContainer.getAttribute("dir"));
lengthHint.innerText = `(${wordLengths.join(", ")})`;
wordContainer.appendChild(lengthHint);
};
const set_dummy_word_hints = () => {
// Dummy wordhint to prevent layout changes.
applyWordHints(
[
{
character: "D",
underline: true,
},
],
true,
);
};
set_dummy_word_hints();
const applyDrawData = (drawElements) => {
clear(context);
drawElements.forEach((drawElement) => {
const drawData = drawElement.data;
if (drawElement.type === "fill") {
floodfillUint8ClampedArray(
imageData.data,
drawData.x,
drawData.y,
indexToRgbColor(drawData.color),
imageData.width,
imageData.height,
);
} else if (drawElement.type === "line") {
drawLineNoPut(
context,
imageData,
drawData.x,
drawData.y,
drawData.x2,
drawData.y2,
indexToRgbColor(drawData.color),
drawData.width,
);
} else {
console.log("Unknown draw element type: " + drawData.type);
}
});
context.putImageData(imageData, 0, 0);
};
let lastX = 0;
let lastY = 0;
let touchID = null;
function onTouchStart(event) {
//We only allow a single touch
if (allowDrawing && touchID == null && localTool !== fillBucket) {
const touch = event.touches[0];
touchID = touch.identifier;
// calculate the offset coordinates based on client touch position and drawing board client origin
const clientRect = drawingBoard.getBoundingClientRect();
lastX = touch.clientX - clientRect.left;
lastY = touch.clientY - clientRect.top;
}
}
function onTouchMove(event) {
// Prevent moving, scrolling or zooming the page
event.preventDefault();
if (allowDrawing) {
for (let i = event.changedTouches.length - 1; i >= 0; i--) {
if (event.changedTouches[i].identifier === touchID) {
const touch = event.changedTouches[i];
// calculate the offset coordinates based on client touch position and drawing board client origin
const clientRect = drawingBoard.getBoundingClientRect();
const offsetX = touch.clientX - clientRect.left;
const offsetY = touch.clientY - clientRect.top;
// drawing functions must check for context boundaries
drawLineAndSendEvent(context, lastX, lastY, offsetX, offsetY);
lastX = offsetX;
lastY = offsetY;
return;
}
}
}
}
function onTouchEnd(event) {
for (let i = event.changedTouches.length - 1; i >= 0; i--) {
if (event.changedTouches[i].identifier === touchID) {
touchID = null;
return;
}
}
}
drawingBoard.addEventListener("touchend", onTouchEnd);
drawingBoard.addEventListener("touchcancel", onTouchEnd);
drawingBoard.addEventListener("touchstart", onTouchStart);
drawingBoard.addEventListener("touchmove", onTouchMove);
function onMouseDown(event) {
if (
allowDrawing &&
event.pointerType !== "touch" &&
event.buttons === 1 &&
localTool !== fillBucket
) {
const clientRect = drawingBoard.getBoundingClientRect();
lastX = event.clientX - clientRect.left;
lastY = event.clientY - clientRect.top;
}
}
function pressureToLineWidth(event) {
//event.button === 0 could be wrong, as it can also be the uninitialized state.
//Therefore we use event.buttons, which works differently.
if (
event.buttons !== 1 ||
event.pressure === 0 ||
event.pointerType === "touch"
) {
return 0;
}
if (!penPressure || event.pressure === 0.5 || !event.pressure) {
return localLineWidth;
}
return Math.ceil(event.pressure * 32);
}
// Previously the onMouseMove handled leave, but we do this separately now for
// proper pen support. Otherwise leave leads to a loss of the pen pressure, as
// we are handling that with mouseleave instead of pointerleave. pointerlave
// is not triggered until the pen is let go.
function onMouseLeave(event) {
if (allowDrawing && lastLineWidth && localTool !== fillBucket) {
// calculate the offset coordinates based on client mouse position and drawing board client origin
const clientRect = drawingBoard.getBoundingClientRect();
const offsetX = event.clientX - clientRect.left;
const offsetY = event.clientY - clientRect.top;
// drawing functions must check for context boundaries
drawLineAndSendEvent(
context,
lastX,
lastY,
offsetX,
offsetY,
lastLineWidth,
);
lastX = offsetX;
lastY = offsetY;
}
}
let lastLineWidth;
function onMouseMove(event) {
const pressureLineWidth = pressureToLineWidth(event);
lastLineWidth = pressureLineWidth;
if (allowDrawing && pressureLineWidth && localTool !== fillBucket) {
// calculate the offset coordinates based on client mouse position and drawing board client origin
const clientRect = drawingBoard.getBoundingClientRect();
const offsetX = event.clientX - clientRect.left;
const offsetY = event.clientY - clientRect.top;
// drawing functions must check for context boundaries
drawLineAndSendEvent(
context,
lastX,
lastY,
offsetX,
offsetY,
pressureLineWidth,
);
lastX = offsetX;
lastY = offsetY;
}
}
function onMouseClick(event) {
//event.buttons won't work here, since it's always 0. Since we
//have a click event, we can be sure that we actually had a button
//clicked and 0 won't be the uninitialized state.
if (allowDrawing && event.button === 0) {
if (localTool === fillBucket) {
fillAndSendEvent(
context,
event.offsetX,
event.offsetY,
localColorIndex,
);
} else {
drawLineAndSendEvent(
context,
event.offsetX,
event.offsetY,
event.offsetX,
event.offsetY,
);
}
}
}
drawingBoard.addEventListener("pointerdown", onMouseDown);
drawingBoard.addEventListener("pointermove", onMouseMove);
drawingBoard.addEventListener("mouseleave", onMouseLeave);
drawingBoard.addEventListener("click", onMouseClick);
function onGlobalMouseMove(event) {
const clientRect = drawingBoard.getBoundingClientRect();
lastX = Math.min(
clientRect.width - 1,
Math.max(0, event.clientX - clientRect.left),
);
lastY = Math.min(
clientRect.height - 1,
Math.max(0, event.clientY - clientRect.top),
);
}
//necessary for mousemove to not use the previous exit coordinates.
//If this is done via mouseleave and mouseenter of the
//drawingBoard, the lines will end too early on leave and start
//too late on exit.
window.addEventListener("mousemove", onGlobalMouseMove);
function isAnyDialogVisible() {
for (let i = 0; i < centerDialogs.children.length; i++) {
if (centerDialogs.children[i].style.visibility === "visible") {
return true;
}
}
return false;
}
function onKeyDown(event) {
//Avoid firing actions if the user is in the chat.
if (document.activeElement instanceof HTMLInputElement) {
return;
}
//If dialogs are open, it doesn't really make sense to be able to
//change tools. As this is like being in the pause menu of a game.
if (isAnyDialogVisible()) {
return;
}
//They key choice was made like this, since it's easy to remember
//and easy to reach. This is how many MOBAs do it and I personally
//find it better than having to find specific keys on your
//keyboard. Especially for people that aren't used to typing
//without looking at their keyboard, this might help.
if (event.key === "q") {
toolButtonPen.click();
chooseTool(pen);
} else if (event.key === "w") {
toolButtonFill.click();
chooseTool(fillBucket);
} else if (event.key === "e") {
toolButtonRubber.click();
chooseTool(rubber);
} else if (event.key === "1") {
sizeButton8.click();
setLineWidth(8);
} else if (event.key === "2") {
sizeButton16.click();
setLineWidth(16);
} else if (event.key === "3") {
sizeButton24.click();
setLineWidth(24);
} else if (event.key === "4") {
sizeButton32.click();
setLineWidth(32);
} else if (event.key === "z" && event.ctrlKey) {
undoAndSendEvent();
}
}
//Handling events on the canvas directly isn't possible, since the user
//must've clicked it at least once in order for that to work.
window.addEventListener("keydown", onKeyDown);
function debounce(func, timeout) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}
function clear(context) {
context.fillStyle = "#FFFFFF";
context.fillRect(0, 0, drawingBoard.width, drawingBoard.height);
// Refetch, as we don't manually fill here.
imageData = context.getImageData(
0,
0,
context.canvas.width,
context.canvas.height,
);
}
// Clear initially, as it will be black otherwise.
clear(context);
function fillAndSendEvent(context, x, y, colorIndex) {
const xScaled = convertToServerCoordinate(x);
const yScaled = convertToServerCoordinate(y);
const color = indexToRgbColor(colorIndex);
if (
floodfillUint8ClampedArray(
imageData.data,
xScaled,
yScaled,
color,
imageData.width,
imageData.height,
)
) {
context.putImageData(imageData, 0, 0);
const fillInstruction = {
type: "fill",
data: {
x: xScaled,
y: yScaled,
color: colorIndex,
},
};
socket.send(JSON.stringify(fillInstruction));
}
}
function drawLineAndSendEvent(
context,
x1,
y1,
x2,
y2,
lineWidth = localLineWidth,
) {
const color = localTool === rubber ? rubberColor : localColor;
const colorIndex = localTool === rubber ? 0 /* white */ : localColorIndex;
const x1Scaled = convertToServerCoordinate(x1);
const y1Scaled = convertToServerCoordinate(y1);
const x2Scaled = convertToServerCoordinate(x2);
const y2Scaled = convertToServerCoordinate(y2);
drawLine(
context,
imageData,
x1Scaled,
y1Scaled,
x2Scaled,
y2Scaled,
color,
lineWidth,
);
const drawInstruction = {
type: "line",
data: {
x: x1Scaled,
y: y1Scaled,
x2: x2Scaled,
y2: y2Scaled,
color: colorIndex,
width: lineWidth,
},
};
socket.send(JSON.stringify(drawInstruction));
}
function getCookie(name) {
let cookie = {};
document.cookie.split(";").forEach(function (el) {
let split = el.split("=");
cookie[split[0].trim()] = split.slice(1).join("=");
});
return cookie[name];
}
function isString(obj) {
return typeof obj === "string";
}
function isObject(obj) {
return (
typeof obj === "object" &&
obj !== null &&
!Array.isArray(obj) &&
Object.prototype.toString.call(obj) === "[object Object]"
);
}
const connectToWebsocket = () => {
if (socketIsConnecting === true) {
return;
}
socketIsConnecting = true;
socket = new WebSocket(`${rootPath}/v1/lobby/ws`);
socket.onerror = (error) => {
//Is not connected and we haven't yet said that we are done trying to
//connect, this means that we could never even establish a connection.
if (socket.readyState != 1 && !hasSocketEverConnected) {
socketIsConnecting = false;
showTextDialog(
"connection-error-dialog",
'{{.Translation.Get "error-connecting"}}',
`{{.Translation.Get "error-connecting-text"}}`,
);
console.log("Error establishing connection: ", error);
} else {
console.log("Socket error: ", error);
}
};
socket.onopen = () => {
closeDialog(reconnectDialogId);
hasSocketEverConnected = true;
socketIsConnecting = false;
socket.onclose = (event) => {
//We want to avoid handling the error multiple times and showing the incorrect dialogs.
socket.onerror = null;
console.log("Socket Closed Connection: ", event);
if (event.code === 4000) {
showTextDialog(
reconnectDialogId,
"Kicked",
`You have been kicked from the lobby.`,
);
} else {
console.log("Attempting to reestablish socket connection.");
showReconnectDialogIfNotShown();
connectToWebsocket();
}
};
socket.onmessage = (jsonMessage) => {
handleEvent(JSON.parse(jsonMessage.data));
};
console.log("Successfully Connected");
};
};
connectToWebsocket();
//In order to avoid automatically canceling the socket connection, we keep
//sending dummy events every 5 seconds. This was a problem on Heroku. If
//a player took a very long time to choose a word, the connection of all
//players could be killed and even cause the lobby being closed. Since
//that's very frustrating, we want to avoid that.
window.setInterval(() => {
if (socket) {
socket.send(JSON.stringify({ type: "keep-alive" }));
}
}, 5000);
================================================
FILE: internal/frontend/lobby_test.go
================================================
package frontend
import (
"testing"
"github.com/scribble-rs/scribble.rs/internal/api"
"github.com/scribble-rs/scribble.rs/internal/config"
"github.com/scribble-rs/scribble.rs/internal/game"
)
func TestCreateLobby(t *testing.T) {
t.Parallel()
data := api.CreateLobbyData(
&config.Default,
&game.Lobby{
LobbyID: "TEST",
})
var previousSize uint8
for _, suggestedSize := range data.SuggestedBrushSizes {
if suggestedSize < previousSize {
t.Error("Sorting in SuggestedBrushSizes is incorrect")
}
}
for _, suggestedSize := range data.SuggestedBrushSizes {
if suggestedSize < game.MinBrushSize {
t.Errorf("suggested brushsize %d is below MinBrushSize %d", suggestedSize, game.MinBrushSize)
}
if suggestedSize > game.MaxBrushSize {
t.Errorf("suggested brushsize %d is above MaxBrushSize %d", suggestedSize, game.MaxBrushSize)
}
}
}
================================================
FILE: internal/frontend/resources/draw.js
================================================
//Notice for core code of the floodfill, which has since then been heavily
//changed.
//Copyright(c) Max Irwin - 2011, 2015, 2016
//Repo: https://github.com/binarymax/floodfill.js
//MIT License
function floodfillData(data, x, y, fillcolor, width, height) {
const length = data.length;
let i = (x + y * width) * 4;
//Fill coordinates are out of bounds
if (i < 0 || i >= length) {
return false;
}
//We check whether the target pixel is already the desired color, since
//filling wouldn't change any of the pixels in this case.
const targetcolor = [data[i], data[i + 1], data[i + 2]];
if (
targetcolor[0] === fillcolor.r &&
targetcolor[1] === fillcolor.g &&
targetcolor[2] === fillcolor.b) {
return false;
}
let e = i, w = i, me, mw, w2 = width * 4;
let j;
//Previously we used Array.push and Array.pop here, with which the method
//took between 70ms and 80ms on a rather strong machine with a FULL HD monitor.
//Since Q can never be required to be bigger than the amount of maximum
//pixels (width*height), we preallocate Q with that size. While not all of
//the space might be needed, this is cheaper than reallocating multiple times.
//This improved the time from 70ms-80ms to 50ms-60ms.
const Q = new Array(width * height);
let nextQIndex = 0;
Q[nextQIndex++] = i;
while (nextQIndex > 0) {
i = Q[--nextQIndex];
if (pixelCompareAndSet(i, targetcolor, fillcolor, data)) {
e = i;
w = i;
mw = Math.floor(i / w2) * w2; //left bound
me = mw + w2; //right bound
while (mw < w && mw <= (w -= 4) && pixelCompareAndSet(w, targetcolor, fillcolor, data)); //go left until edge hit
while (me > e && me > (e += 4) && pixelCompareAndSet(e, targetcolor, fillcolor, data)); //go right until edge hit
for (j = w + 4; j < e; j += 4) {
if (j - w2 >= 0 && pixelCompare(j - w2, targetcolor, data)) Q[nextQIndex++] = j - w2; //queue y-1
if (j + w2 < length && pixelCompare(j + w2, targetcolor, data)) Q[nextQIndex++] = j + w2; //queue y+1
}
}
}
return data;
};
function pixelCompare(i, targetcolor, data) {
return (
targetcolor[0] === data[i] &&
targetcolor[1] === data[i + 1] &&
targetcolor[2] === data[i + 2]
);
};
function pixelCompareAndSet(i, targetcolor, fillcolor, data) {
if (pixelCompare(i, targetcolor, data)) {
data[i] = fillcolor.r;
data[i + 1] = fillcolor.g;
data[i + 2] = fillcolor.b;
return true;
}
return false;
};
function floodfillUint8ClampedArray(data, x, y, color, width, height) {
if (isNaN(width) || width < 1) throw new Error("argument 'width' must be a positive integer");
if (isNaN(height) || height < 1) throw new Error("argument 'height' must be a positive integer");
if (isNaN(x) || x < 0) throw new Error("argument 'x' must be a positive integer");
if (isNaN(y) || y < 0) throw new Error("argument 'y' must be a positive integer");
if (width * height * 4 !== data.length) throw new Error("width and height do not fit Uint8ClampedArray dimensions");
const xi = Math.floor(x);
const yi = Math.floor(y);
return floodfillData(data, xi, yi, color, width, height);
};
// Code for line drawing, not related to the floodfill repo.
// Hence it's all BSD licensed.
function drawLine(context, imageData, x1, y1, x2, y2, color, width) {
const coords = prepareDrawLineCoords(context, x1, y1, x2, y2, width);
_drawLineNoPut(imageData, coords, color, width);
context.putImageData(imageData, 0, 0, 0, 0, coords.right, coords.bottom);
};
// This implementation directly access the canvas data and does not
// put it back into the canvas context directly. This saved us not
// only from calling put, which is relatively cheap, but also from
// calling getImageData all the time.
function drawLineNoPut(context, imageData, x1, y1, x2, y2, color, width) {
_drawLineNoPut(imageData, prepareDrawLineCoords(context, x1, y1, x2, y2, width), color, width);
};
function _drawLineNoPut(imageData, coords, color, width) {
const { x1, y1, x2, y2, left, top, right, bottom } = coords;
// off canvas, so don't draw anything
if (right - left === 0 || bottom - top === 0) {
return;
}
const circleMap = generateCircleMap(Math.floor(width / 2));
const offset = Math.floor(circleMap.length / 2);
for (let ix = 0; ix < circleMap.length; ix++) {
for (let iy = 0; iy < circleMap[ix].length; iy++) {
if (circleMap[ix][iy] === 1 || (x1 === x2 && y1 === y2 && circleMap[ix][iy] === 2)) {
const newX1 = x1 + ix - offset;
const newY1 = y1 + iy - offset;
const newX2 = x2 + ix - offset;
const newY2 = y2 + iy - offset;
drawBresenhamLine(imageData, newX1, newY1, newX2, newY2, color);
}
}
}
}
function prepareDrawLineCoords(context, x1, y1, x2, y2, width) {
// the coordinates must be whole numbers to improve performance.
// also, decimals as coordinates is not making sense.
x1 = Math.floor(x1);
y1 = Math.floor(y1);
x2 = Math.floor(x2);
y2 = Math.floor(y2);
// calculate bounding box
const left = Math.max(0, Math.min(context.canvas.width, Math.min(x1, x2) - width));
const top = Math.max(0, Math.min(context.canvas.height, Math.min(y1, y2) - width));
const right = Math.max(0, Math.min(context.canvas.width, Math.max(x1, x2) + width));
const bottom = Math.max(0, Math.min(context.canvas.height, Math.max(y1, y2) + width));
return {
x1: x1,
y1: y1,
x2: x2,
y2: y2,
left: left,
top: top,
right: right,
bottom: bottom,
};
}
function drawBresenhamLine(imageData, x1, y1, x2, y2, color) {
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = (x1 < x2) ? 1 : -1;
const sy = (y1 < y2) ? 1 : -1;
let err = dx - dy;
while (true) {
//check if pixel is inside the canvas
if (!(x1 < 0 || x1 >= imageData.width || y1 < 0 || y1 >= imageData.height)) {
setPixel(imageData, x1, y1, color);
}
if ((x1 === x2) && (y1 === y2)) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x1 += sx;
}
if (e2 < dx) {
err += dx;
y1 += sy;
}
}
}
// We cache them, as we need quite a lot of them, but the pencil size usually
// doesn't change that often. There's also not many sizes, so we don't need to
// worry about invalidating anything.
let cachedCircleMaps = {};
function generateCircleMap(radius) {
const cached = cachedCircleMaps[radius];
if (cached) {
return cached;
}
const diameter = 2 * radius;
const circleData = new Array(diameter);
for (let x = 0; x < diameter; x++) {
circleData[x] = new Array(diameter);
for (let y = 0; y < diameter; y++) {
const distanceToRadius = Math.sqrt(Math.pow(radius - x, 2) + Math.pow(radius - y, 2));
if (distanceToRadius > radius) {
circleData[x][y] = 0;
} else if (distanceToRadius < radius - 2) {
circleData[x][y] = 2;
} else {
circleData[x][y] = 1;
}
}
}
cachedCircleMaps[radius] = circleData;
return circleData;
}
function setPixel(imageData, x, y, color) {
const offset = (y * imageData.width + x) * 4;
imageData.data[offset] = color.r;
imageData.data[offset + 1] = color.g;
imageData.data[offset + 2] = color.b;
}
//We accept both #RRGGBB and RRGGBB. Both are treated case insensitive.
function hexStringToRgbColorObject(hexString) {
if (!hexString) {
return { r: 0, g: 0, b: 0 };
}
const hexColorsRegex = /#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i;
const match = hexString.match(hexColorsRegex)
return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16) };
}
const colorMap = [
{ hex: '#ffffff', rgb: hexStringToRgbColorObject('#ffffff') },
{ hex: '#c1c1c1', rgb: hexStringToRgbColorObject('#c1c1c1') },
{ hex: '#ef130b', rgb: hexStringToRgbColorObject('#ef130b') },
{ hex: '#ff7100', rgb: hexStringToRgbColorObject('#ff7100') },
{ hex: '#ffe400', rgb: hexStringToRgbColorObject('#ffe400') },
{ hex: '#00cc00', rgb: hexStringToRgbColorObject('#00cc00') },
{ hex: '#00b2ff', rgb: hexStringToRgbColorObject('#00b2ff') },
{ hex: '#231fd3', rgb: hexStringToRgbColorObject('#231fd3') },
{ hex: '#a300ba', rgb: hexStringToRgbColorObject('#a300ba') },
{ hex: '#d37caa', rgb: hexStringToRgbColorObject('#d37caa') },
{ hex: '#a0522d', rgb: hexStringToRgbColorObject('#a0522d') },
{ hex: '#592f2a', rgb: hexStringToRgbColorObject('#592f2a') },
{ hex: '#ecbcb4', rgb: hexStringToRgbColorObject('#ecbcb4') },
{ hex: '#000000', rgb: hexStringToRgbColorObject('#000000') },
{ hex: '#4c4c4c', rgb: hexStringToRgbColorObject('#4c4c4c') },
{ hex: '#740b07', rgb: hexStringToRgbColorObject('#740b07') },
{ hex: '#c23800', rgb: hexStringToRgbColorObject('#c23800') },
{ hex: '#e8a200', rgb: hexStringToRgbColorObject('#e8a200') },
{ hex: '#005510', rgb: hexStringToRgbColorObject('#005510') },
{ hex: '#00569e', rgb: hexStringToRgbColorObject('#00569e') },
{ hex: '#0e0865', rgb: hexStringToRgbColorObject('#0e0865') },
{ hex: '#550069', rgb: hexStringToRgbColorObject('#550069') },
{ hex: '#a75574', rgb: hexStringToRgbColorObject('#a75574') },
{ hex: '#63300d', rgb: hexStringToRgbColorObject('#63300d') },
{ hex: '#492f31', rgb: hexStringToRgbColorObject('#492f31') },
{ hex: '#d1a3a4', rgb: hexStringToRgbColorObject('#d1a3a4') }
];
function indexToHexColor(index) {
return colorMap[index].hex;
}
function indexToRgbColor(index) {
return colorMap[index].rgb;
}
================================================
FILE: internal/frontend/resources/error.css
================================================
.error-pane-wrapper {
flex: 1;
display: flex;
flex-direction: column;
}
.error-pane {
display: inline-block;
margin: auto;
background-color: white;
padding: 1rem;
border-radius: 1rem;
}
.error-title,
.error-message {
display: block;
}
.error-title {
font-size: 6rem;
}
.error-message {
font-size: 3rem;
margin: 2vw;
}
.go-back,
.go-back:link,
.go-back:visited {
display: block;
font-size: 3rem;
color: rgb(248, 148, 164);
text-align: center;
}
================================================
FILE: internal/frontend/resources/index.css
================================================
* {
border: none;
margin: 0;
}
/*root end*/
#logo {
width: 50vw;
margin-top: 1rem;
margin-bottom: 1rem;
margin-left: auto;
margin-right: auto;
}
#home-choices {
display: flex;
flex-direction: row;
gap: 1rem;
justify-content: center;
}
.home-choice-header {
display: flex;
}
.home-choice-title {
font-size: 1.25rem;
font-weight: bold;
flex: 1;
}
.home-choice-inner {
row-gap: 1rem;
display: flex;
flex-direction: column;
height: 100%;
}
.home-choice {
background-color: var(--pane-background);
padding: 1.5rem;
border-radius: 1.3rem;
overflow: hidden;
width: min(42.5vw, 35rem);
height: 59vh;
}
.home {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
#lobby-list {
display: flex;
flex-direction: column;
overflow-y: auto;
gap: 0.75rem;
padding: 0.25rem;
min-height: 10rem;
}
.lobby-list-item {
background-color: white;
padding: 1rem;
border-radius: 0.75rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1.25rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.lobby-list-item:hover {
background-color: #fafafa;
}
.lobby-list-rows {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
}
.lobby-list-row {
display: flex;
flex-direction: row;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.language-flag {
font-size: 3rem;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
margin-right: 0.25rem;
}
.lobby-list-item-info-pair {
display: flex;
flex-direction: row;
gap: 0.35rem;
align-items: center;
background: #f8f9fa;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
}
.lobby-list-item-icon {
width: 1.1rem;
height: 1.1rem;
}
.lobby-list-icon-loading {
border-radius: 0.75rem;
background-color: black;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
background-color: rgb(161, 160, 160);
}
50% {
background-color: rgb(228, 228, 228);
}
100% {
background-color: rgb(161, 160, 160);
}
}
.lobby-list-placeholder {
display: flex;
width: 100%;
flex: 1;
}
.lobby-list-placeholder > * {
margin: auto;
max-width: 80%;
}
.join-button {
background-color: rgb(38, 187, 38);
color: white;
font-weight: 800;
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
border-radius: 0.6rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.05rem;
transition:
background-color 0.2s,
transform 0.1s;
height: unset;
width: unset;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.join-button:hover {
background-color: rgb(34, 167, 34);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
.join-button:active {
transform: scale(0.96);
}
.custom-tag {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
background-color: #f0f0f0;
color: #666;
border-radius: 2r
gitextract_v5hmk19x/ ├── .dockerignore ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── docker-image-update.yml │ ├── release.yml │ ├── test-and-build.yml │ └── test-pr.yml ├── .gitignore ├── .golangci.yml ├── .vscode/ │ ├── launch.json │ └── settings.json ├── LICENSE ├── README.md ├── cmd/ │ └── scribblers/ │ └── main.go ├── fly.Dockerfile ├── fly.toml ├── fly_deploy.sh ├── go.mod ├── go.sum ├── internal/ │ ├── api/ │ │ ├── createparse.go │ │ ├── createparse_test.go │ │ ├── doc.go │ │ ├── http.go │ │ ├── v1.go │ │ └── ws.go │ ├── config/ │ │ └── config.go │ ├── frontend/ │ │ ├── doc.go │ │ ├── http.go │ │ ├── index.go │ │ ├── index.js │ │ ├── lobby.go │ │ ├── lobby.js │ │ ├── lobby_test.go │ │ ├── resources/ │ │ │ ├── draw.js │ │ │ ├── error.css │ │ │ ├── index.css │ │ │ ├── lobby.css │ │ │ └── root.css │ │ ├── templates/ │ │ │ ├── error.html │ │ │ ├── favicon.html │ │ │ ├── footer.html │ │ │ ├── index.html │ │ │ ├── lobby.html │ │ │ └── non-static-css.html │ │ └── templating_test.go │ ├── game/ │ │ ├── data.go │ │ ├── data_test.go │ │ ├── lobby.go │ │ ├── lobby_test.go │ │ ├── shared.go │ │ ├── words/ │ │ │ ├── ar │ │ │ ├── de │ │ │ ├── en_gb │ │ │ ├── en_us │ │ │ ├── fa │ │ │ ├── fr │ │ │ ├── he │ │ │ ├── it │ │ │ ├── nl │ │ │ ├── pl │ │ │ ├── ru │ │ │ └── ua │ │ ├── words.go │ │ └── words_test.go │ ├── metrics/ │ │ └── metrics.go │ ├── sanitize/ │ │ └── sanitize.go │ ├── state/ │ │ ├── doc.go │ │ ├── lobbies.go │ │ └── lobbies_test.go │ ├── translations/ │ │ ├── ar.go │ │ ├── de_DE.go │ │ ├── doc.go │ │ ├── en_us.go │ │ ├── es_ES.go │ │ ├── fa.go │ │ ├── fr_FR.go │ │ ├── he.go │ │ ├── pl.go │ │ ├── translations.go │ │ └── translations_test.go │ └── version/ │ └── version.go ├── linux.Dockerfile ├── tools/ │ ├── compare_en_words.sh │ ├── sanitizer/ │ │ ├── README.md │ │ └── main.go │ ├── simulate/ │ │ └── main.go │ ├── skribbliohintsconverter/ │ │ ├── README.md │ │ ├── english.json │ │ ├── german.json │ │ └── main.go │ ├── statcollector/ │ │ └── main.go │ └── translate.sh └── windows.Dockerfile
SYMBOL INDEX (408 symbols across 41 files)
FILE: cmd/scribblers/main.go
function main (line 23) | func main() {
FILE: internal/api/createparse.go
function ParsePlayerName (line 17) | func ParsePlayerName(value string) (string, error) {
function ParseLanguage (line 28) | func ParseLanguage(value string) (*game.LanguageData, string, error) {
function ParseScoreCalculation (line 39) | func ParseScoreCalculation(value string) (game.ScoreCalculation, error) {
function ParseDrawingTime (line 54) | func ParseDrawingTime(cfg *config.Config, value string) (int, error) {
function ParseRounds (line 62) | func ParseRounds(cfg *config.Config, value string) (int, error) {
function ParseMaxPlayers (line 70) | func ParseMaxPlayers(cfg *config.Config, value string) (int, error) {
function ParseCustomWords (line 83) | func ParseCustomWords(lowercaser cases.Caser, value string) ([]string, e...
function ParseClientsPerIPLimit (line 104) | func ParseClientsPerIPLimit(cfg *config.Config, value string) (int, erro...
function ParseCustomWordsPerTurn (line 112) | func ParseCustomWordsPerTurn(cfg *config.Config, value string) (int, err...
function ParseWordsPerTurn (line 116) | func ParseWordsPerTurn(cfg *config.Config, value string) (int, error) {
function newIntOutOfBounds (line 120) | func newIntOutOfBounds(value, valueName string, lower, upper int) error {
function parseIntValue (line 127) | func parseIntValue(toParse string, lower, upper int, valueName string) (...
function ParseBoolean (line 145) | func ParseBoolean(valueName, value string) (bool, error) {
FILE: internal/api/createparse_test.go
function Test_parsePlayerName (line 13) | func Test_parsePlayerName(t *testing.T) {
function Test_parseDrawingTime (line 44) | func Test_parseDrawingTime(t *testing.T) {
function Test_parseRounds (line 77) | func Test_parseRounds(t *testing.T) {
function Test_parseMaxPlayers (line 110) | func Test_parseMaxPlayers(t *testing.T) {
function Test_parseCustomWords (line 143) | func Test_parseCustomWords(t *testing.T) {
function Test_parseCustomWordsPerTurn (line 178) | func Test_parseCustomWordsPerTurn(t *testing.T) {
function Test_parseWordsPerTurn (line 219) | func Test_parseWordsPerTurn(t *testing.T) {
function Test_parseBoolean (line 258) | func Test_parseBoolean(t *testing.T) {
FILE: internal/api/http.go
method SetupRoutes (line 12) | func (handler *V1Handler) SetupRoutes(rootPath string, register func(str...
function remoteAddressToSimpleIP (line 42) | func remoteAddressToSimpleIP(input string) string {
function GetIPAddressFromRequest (line 54) | func GetIPAddressFromRequest(request *http.Request) string {
FILE: internal/api/v1.go
type V1Handler (line 24) | type V1Handler struct
method getLobbies (line 63) | func (handler *V1Handler) getLobbies(writer http.ResponseWriter, _ *ht...
method postLobby (line 95) | func (handler *V1Handler) postLobby(writer http.ResponseWriter, reques...
method postPlayer (line 225) | func (handler *V1Handler) postPlayer(writer http.ResponseWriter, reque...
method patchLobby (line 346) | func (handler *V1Handler) patchLobby(writer http.ResponseWriter, reque...
method getStats (line 457) | func (handler *V1Handler) getStats(writer http.ResponseWriter, _ *http...
function NewHandler (line 28) | func NewHandler(cfg *config.Config) *V1Handler {
function marshalToHTTPWriter (line 34) | func marshalToHTTPWriter(data any, writer http.ResponseWriter) (bool, er...
type LobbyEntries (line 46) | type LobbyEntries
type LobbyEntry (line 49) | type LobbyEntry struct
function GetDiscordInstanceId (line 272) | func GetDiscordInstanceId(request *http.Request) string {
constant discordDomain (line 283) | discordDomain = "1320396325925163070.discordsays.com"
function SetDiscordCookies (line 285) | func SetDiscordCookies(w http.ResponseWriter, request *http.Request) {
function SetGameplayCookies (line 302) | func SetGameplayCookies(
type GameConstants (line 473) | type GameConstants struct
type LobbyData (line 504) | type LobbyData struct
function CreateLobbyData (line 513) | func CreateLobbyData(cfg *config.Config, lobby *game.Lobby) *LobbyData {
function GetUserSession (line 525) | func GetUserSession(request *http.Request) (uuid.UUID, error) {
function GetPlayer (line 547) | func GetPlayer(lobby *game.Lobby, request *http.Request) *game.Player {
function GetPlayername (line 563) | func GetPlayername(request *http.Request) string {
function GetLobbyId (line 581) | func GetLobbyId(request *http.Request) string {
FILE: internal/api/ws.go
method websocketUpgrade (line 30) | func (handler *V1Handler) websocketUpgrade(writer http.ResponseWriter, r...
constant pingInterval (line 83) | pingInterval = 10 * time.Second
constant pingWait (line 84) | pingWait = 5 * time.Second
type socketHandler (line 87) | type socketHandler struct
method resetDeadline (line 89) | func (c *socketHandler) resetDeadline(socket *gws.Conn) {
method OnOpen (line 95) | func (c *socketHandler) OnOpen(socket *gws.Conn) {
method OnClose (line 103) | func (c *socketHandler) OnClose(socket *gws.Conn, _ error) {
method OnPing (line 121) | func (c *socketHandler) OnPing(socket *gws.Conn, _ []byte) {
method OnPong (line 126) | func (c *socketHandler) OnPong(socket *gws.Conn, _ []byte) {
method OnMessage (line 130) | func (c *socketHandler) OnMessage(socket *gws.Conn, message *gws.Messa...
function extract (line 99) | func extract(x any, _ bool) any {
function handleIncommingEvent (line 147) | func handleIncommingEvent(lobby *game.Lobby, player *game.Player, data [...
function WriteObject (line 173) | func WriteObject(player *game.Player, object any) error {
function WritePreparedMessage (line 194) | func WritePreparedMessage(player *game.Player, message *gws.Broadcaster)...
FILE: internal/config/config.go
type LobbySettingDefaults (line 17) | type LobbySettingDefaults struct
type CORS (line 30) | type CORS struct
type LobbyCleanup (line 35) | type LobbyCleanup struct
type Config (line 45) | type Config struct
function Load (line 118) | func Load() (*Config, error) {
FILE: internal/frontend/http.go
function init (line 27) | func init() {
type BasePageConfig (line 37) | type BasePageConfig struct
method Hash (line 65) | func (baseConfig *BasePageConfig) Hash(key string, bytes []byte) error {
method withCacheBust (line 81) | func (baseConfig *BasePageConfig) withCacheBust(file string) string {
method WithCacheBust (line 90) | func (baseConfig *BasePageConfig) WithCacheBust(file string) template....
method cspMiddleware (line 94) | func (handler *SSRHandler) cspMiddleware(handleFunc http.HandlerFunc) ht...
method SetupRoutes (line 102) | func (handler *SSRHandler) SetupRoutes(register func(string, string, htt...
type errorPageData (line 161) | type errorPageData struct
method userFacingError (line 171) | func (handler *SSRHandler) userFacingError(w http.ResponseWriter, errorM...
function isHumanAgent (line 183) | func isHumanAgent(userAgent string) bool {
FILE: internal/frontend/index.go
type indexJsData (line 31) | type indexJsData struct
type SSRHandler (line 40) | type SSRHandler struct
method indexJs (line 112) | func (handler *SSRHandler) indexJs(writer http.ResponseWriter, request...
method indexPageHandler (line 131) | func (handler *SSRHandler) indexPageHandler(writer http.ResponseWriter...
method createDefaultIndexPageData (line 156) | func (handler *SSRHandler) createDefaultIndexPageData() *IndexPageData {
method ssrCreateLobby (line 181) | func (handler *SSRHandler) ssrCreateLobby(writer http.ResponseWriter, ...
function NewHandler (line 47) | func NewHandler(cfg *config.Config) (*SSRHandler, error) {
type IndexPageData (line 167) | type IndexPageData struct
FILE: internal/frontend/index.js
function getCookie (line 235) | function getCookie(name) {
FILE: internal/frontend/lobby.go
type lobbyPageData (line 15) | type lobbyPageData struct
type lobbyJsData (line 23) | type lobbyJsData struct
method lobbyJs (line 31) | func (handler *SSRHandler) lobbyJs(writer http.ResponseWriter, request *...
method ssrEnterLobby (line 50) | func (handler *SSRHandler) ssrEnterLobby(writer http.ResponseWriter, req...
method ssrEnterLobbyNoChecks (line 71) | func (handler *SSRHandler) ssrEnterLobbyNoChecks(
function determineTranslation (line 127) | func determineTranslation(r *http.Request) (*translations.Translation, s...
FILE: internal/frontend/lobby.js
function showReconnectDialogIfNotShown (line 13) | function showReconnectDialogIfNotShown() {
function showTextDialog (line 84) | function showTextDialog(id, title, message) {
function hideMenu (line 91) | function hideMenu() {
function showDialog (line 95) | function showDialog(id, title, contentNode, buttonBar) {
function showInfoDialog (line 124) | function showInfoDialog(title, message, buttonText) {
function createDialogButton (line 144) | function createDialogButton(text) {
function createDialogButtonBar (line 151) | function createDialogButtonBar(...buttons) {
function closeDialog (line 158) | function closeDialog(id) {
function showHelpDialog (line 169) | function showHelpDialog() {
function showKickDialog (line 216) | function showKickDialog() {
function hideKickDialog (line 241) | function hideKickDialog() {
function showNameChangeDialog (line 248) | function showNameChangeDialog() {
function hideNameChangeDialog (line 258) | function hideNameChangeDialog() {
function changeName (line 265) | function changeName(name) {
function setUsernameLocally (line 288) | function setUsernameLocally(name) {
function toggleFullscreen (line 294) | function toggleFullscreen() {
function showLobbySettingsDialog (line 305) | function showLobbySettingsDialog() {
function hideLobbySettingsDialog (line 311) | function hideLobbySettingsDialog() {
function saveLobbySettings (line 318) | function saveLobbySettings() {
function toggleSound (line 362) | function toggleSound() {
function updateSoundIcon (line 371) | function updateSoundIcon() {
function togglePenPressure (line 379) | function togglePenPressure() {
function updateTogglePenIcon (line 388) | function updateTogglePenIcon() {
function scaleUpFactor (line 416) | function scaleUpFactor() {
function convertToServerCoordinate (line 427) | function convertToServerCoordinate(value) {
function setColor (line 475) | function setColor(index) {
function setColorNoUpdate (line 504) | function setColorNoUpdate(index) {
function setLineWidth (line 515) | function setLineWidth(value) {
function setLineWidthNoUpdate (line 548) | function setLineWidthNoUpdate(value) {
function chooseTool (line 552) | function chooseTool(value) {
function chooseToolNoUpdate (line 578) | function chooseToolNoUpdate(value) {
function rgbColorObjectToHexString (line 587) | function rgbColorObjectToHexString(color) {
function numberTo16BitHexadecimal (line 596) | function numberTo16BitHexadecimal(number) {
function updateDrawingStateUI (line 602) | function updateDrawingStateUI() {
function updateCursor (line 613) | function updateCursor() {
function getComplementaryCursorColor (line 651) | function getComplementaryCursorColor(innerColor) {
function setCircleCursor (line 665) | function setCircleCursor(innerColor, size) {
function generateSVGCircle (line 682) | function generateSVGCircle(circleSize, innerColor, outerColor) {
function toggleSpectate (line 703) | function toggleSpectate() {
function setSpectateMode (line 714) | function setSpectateMode(requestedValue, spectatingValue) {
function toggleReadiness (line 763) | function toggleReadiness() {
function forceStartGame (line 777) | function forceStartGame() {
function clearCanvasAndSendEvent (line 787) | function clearCanvasAndSendEvent() {
function undoAndSendEvent (line 802) | function undoAndSendEvent() {
function sendMessage (line 819) | function sendMessage(event) {
function setAllowDrawing (line 867) | function setAllowDrawing(value) {
function chooseWord (line 878) | function chooseWord(index) {
function onVotekickPlayer (line 889) | function onVotekickPlayer(playerId) {
function showRoundEndMessage (line 1193) | function showRoundEndMessage(previousWord) {
function getCachedPlayer (line 1209) | function getCachedPlayer(playerID) {
function setRoundTimeLeft (line 1236) | function setRoundTimeLeft(timeLeftMs) {
function updateButtonVisibilities (line 1354) | function updateButtonVisibilities() {
function promptWords (line 1362) | function promptWords(data) {
function playWav (line 1376) | function playWav(file) {
function appendMessage (line 1397) | function appendMessage(styleClass, author, message, attrs) {
function applyPlayers (line 1439) | function applyPlayers(players) {
function createPlayerStateImageNode (line 1589) | function createPlayerStateImageNode(path) {
function updateRoundsDisplay (line 1596) | function updateRoundsDisplay() {
function onTouchStart (line 1705) | function onTouchStart(event) {
function onTouchMove (line 1718) | function onTouchMove(event) {
function onTouchEnd (line 1743) | function onTouchEnd(event) {
function onMouseDown (line 1757) | function onMouseDown(event) {
function pressureToLineWidth (line 1770) | function pressureToLineWidth(event) {
function onMouseLeave (line 1790) | function onMouseLeave(event) {
function onMouseMove (line 1812) | function onMouseMove(event) {
function onMouseClick (line 1836) | function onMouseClick(event) {
function onGlobalMouseMove (line 1865) | function onGlobalMouseMove(event) {
function isAnyDialogVisible (line 1883) | function isAnyDialogVisible() {
function onKeyDown (line 1893) | function onKeyDown(event) {
function debounce (line 1940) | function debounce(func, timeout) {
function clear (line 1950) | function clear(context) {
function fillAndSendEvent (line 1965) | function fillAndSendEvent(context, x, y, colorIndex) {
function drawLineAndSendEvent (line 1992) | function drawLineAndSendEvent(
function getCookie (line 2032) | function getCookie(name) {
function isString (line 2041) | function isString(obj) {
function isObject (line 2045) | function isObject(obj) {
FILE: internal/frontend/lobby_test.go
function TestCreateLobby (line 11) | func TestCreateLobby(t *testing.T) {
FILE: internal/frontend/resources/draw.js
function floodfillData (line 7) | function floodfillData(data, x, y, fillcolor, width, height) {
function pixelCompare (line 58) | function pixelCompare(i, targetcolor, data) {
function pixelCompareAndSet (line 66) | function pixelCompareAndSet(i, targetcolor, fillcolor, data) {
function floodfillUint8ClampedArray (line 76) | function floodfillUint8ClampedArray(data, x, y, color, width, height) {
function drawLine (line 92) | function drawLine(context, imageData, x1, y1, x2, y2, color, width) {
function drawLineNoPut (line 102) | function drawLineNoPut(context, imageData, x1, y1, x2, y2, color, width) {
function _drawLineNoPut (line 106) | function _drawLineNoPut(imageData, coords, color, width) {
function prepareDrawLineCoords (line 130) | function prepareDrawLineCoords(context, x1, y1, x2, y2, width) {
function drawBresenhamLine (line 155) | function drawBresenhamLine(imageData, x1, y1, x2, y2, color) {
function generateCircleMap (line 186) | function generateCircleMap(radius) {
function setPixel (line 213) | function setPixel(imageData, x, y, color) {
function hexStringToRgbColorObject (line 221) | function hexStringToRgbColorObject(hexString) {
function indexToHexColor (line 259) | function indexToHexColor(index) {
function indexToRgbColor (line 263) | function indexToRgbColor(index) {
FILE: internal/frontend/templating_test.go
function Test_templateLobbyPage (line 13) | func Test_templateLobbyPage(t *testing.T) {
function Test_templateErrorPage (line 33) | func Test_templateErrorPage(t *testing.T) {
function Test_templateIndexPage (line 49) | func Test_templateIndexPage(t *testing.T) {
FILE: internal/game/data.go
constant slotReservationTime (line 16) | slotReservationTime = time.Minute * 1
type roundEndReason (line 18) | type roundEndReason
constant drawerDisconnected (line 21) | drawerDisconnected roundEndReason = "drawer_disconnected"
constant guessersDisconnected (line 22) | guessersDisconnected roundEndReason = "guessers_disconnected"
type Lobby (line 27) | type Lobby struct
method GetPlayerByID (line 153) | func (lobby *Lobby) GetPlayerByID(id uuid.UUID) *Player {
method GetPlayerBySession (line 163) | func (lobby *Lobby) GetPlayerBySession(userSession uuid.UUID) *Player {
method GetOwner (line 173) | func (lobby *Lobby) GetOwner() *Player {
method ClearDrawing (line 177) | func (lobby *Lobby) ClearDrawing() {
method AppendLine (line 185) | func (lobby *Lobby) AppendLine(line *LineEvent) {
method AppendFill (line 192) | func (lobby *Lobby) AppendFill(fill *FillEvent) {
method GetConnectedPlayerCount (line 217) | func (lobby *Lobby) GetConnectedPlayerCount() int {
method HasConnectedPlayers (line 228) | func (lobby *Lobby) HasConnectedPlayers() bool {
method CanIPConnect (line 244) | func (lobby *Lobby) CanIPConnect(address string) bool {
method IsPublic (line 258) | func (lobby *Lobby) IsPublic() bool {
method GetPlayers (line 262) | func (lobby *Lobby) GetPlayers() []*Player {
method GetOccupiedPlayerSlots (line 271) | func (lobby *Lobby) GetOccupiedPlayerSlots() int {
method HasFreePlayerSlot (line 296) | func (lobby *Lobby) HasFreePlayerSlot() bool {
method Synchronized (line 307) | func (lobby *Lobby) Synchronized(logic func()) {
constant MaxPlayerNameLength (line 113) | MaxPlayerNameLength int = 30
method GetLastKnownAddress (line 116) | func (player *Player) GetLastKnownAddress() string {
method SetLastKnownAddress (line 122) | func (player *Player) SetLastKnownAddress(address string) {
method GetWebsocket (line 129) | func (player *Player) GetWebsocket() *gws.Conn {
method SetWebsocket (line 134) | func (player *Player) SetWebsocket(socket *gws.Conn) {
method GetUserSession (line 139) | func (player *Player) GetUserSession() uuid.UUID {
type PlayerState (line 143) | type PlayerState
constant Guessing (line 146) | Guessing PlayerState = "guessing"
constant Drawing (line 147) | Drawing PlayerState = "drawing"
constant Standby (line 148) | Standby PlayerState = "standby"
constant Ready (line 149) | Ready PlayerState = "ready"
constant Spectating (line 150) | Spectating PlayerState = "spectating"
function SanitizeName (line 198) | func SanitizeName(name string) string {
FILE: internal/game/data_test.go
function TestOccupiedPlayerCount (line 8) | func TestOccupiedPlayerCount(t *testing.T) {
FILE: internal/game/lobby.go
constant DrawingBoardBaseWidth (line 45) | DrawingBoardBaseWidth = 1600
constant DrawingBoardBaseHeight (line 46) | DrawingBoardBaseHeight = 900
constant MinBrushSize (line 47) | MinBrushSize = 8
constant MaxBrushSize (line 48) | MaxBrushSize = 32
type SettingBounds (line 53) | type SettingBounds struct
method HandleEvent (line 69) | func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player...
method handleToggleReadinessEvent (line 209) | func (lobby *Lobby) handleToggleReadinessEvent(player *Player) {
method readyToStart (line 225) | func (lobby *Lobby) readyToStart() bool {
function isRatelimited (line 244) | func isRatelimited(sender *Player) bool {
function handleMessage (line 259) | func handleMessage(message string, sender *Player, lobby *Lobby) {
method wasLastDrawEventFill (line 339) | func (lobby *Lobby) wasLastDrawEventFill() bool {
method isAnyoneStillGuessing (line 347) | func (lobby *Lobby) isAnyoneStillGuessing() bool {
function ExcludePlayer (line 357) | func ExcludePlayer(toExclude *Player) func(*Player) bool {
function IsAllowedToSeeRevealedHints (line 363) | func IsAllowedToSeeRevealedHints(player *Player) bool {
function IsAllowedToSeeHints (line 367) | func IsAllowedToSeeHints(player *Player) bool {
function newMessageEvent (line 371) | func newMessageEvent(messageType, message string, sender *Player) *Event {
method broadcastMessage (line 379) | func (lobby *Lobby) broadcastMessage(message string, sender *Player) {
method Broadcast (line 383) | func (lobby *Lobby) Broadcast(data any) {
method broadcastConditional (line 396) | func (lobby *Lobby) broadcastConditional(data any, condition func(*Playe...
method startGame (line 417) | func (lobby *Lobby) startGame() {
function handleKickVoteEvent (line 434) | func handleKickVoteEvent(lobby *Lobby, player *Player, toKickID uuid.UUI...
function kickPlayer (line 491) | func kickPlayer(lobby *Lobby, playerToKick *Player, playerToKickIndex in...
method Drawer (line 548) | func (lobby *Lobby) Drawer() *Player {
function calculateVotesNeededToKick (line 557) | func calculateVotesNeededToKick(lobby *Lobby) int {
function handleNameChangeEvent (line 576) | func handleNameChangeEvent(caller *Player, lobby *Lobby, name string) {
method calculateGuesserScore (line 594) | func (lobby *Lobby) calculateGuesserScore() int {
method calculateDrawerScore (line 598) | func (lobby *Lobby) calculateDrawerScore() int {
function advanceLobbyPredefineDrawer (line 604) | func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer...
function advanceLobby (line 719) | func advanceLobby(lobby *Lobby) {
method desiresToDraw (line 724) | func (player *Player) desiresToDraw() bool {
function determineNextDrawer (line 736) | func determineNextDrawer(lobby *Lobby) (*Player, bool) {
function startTurnTimeTicker (line 770) | func startTurnTimeTicker(lobby *Lobby, ticker *time.Ticker) {
constant disconnectGrace (line 780) | disconnectGrace = 8 * time.Second
method tickLogic (line 786) | func (lobby *Lobby) tickLogic(expectedTicker *time.Ticker) bool {
method shouldEndEarlyDueToDisconnectedDrawer (line 864) | func (lobby *Lobby) shouldEndEarlyDueToDisconnectedDrawer() bool {
method shouldEndEarlyDueToDisconnectedGuessers (line 880) | func (lobby *Lobby) shouldEndEarlyDueToDisconnectedGuessers() bool {
function getTimeAsMillis (line 915) | func getTimeAsMillis() int64 {
function recalculateRanks (line 921) | func recalculateRanks(lobby *Lobby) {
method selectWord (line 949) | func (lobby *Lobby) selectWord(index int) error {
function CreateLobby (line 1035) | func CreateLobby(
function generatePlayerName (line 1087) | func generatePlayerName() string {
function generateReadyData (line 1091) | func generateReadyData(lobby *Lobby, player *Player) *ReadyEvent {
method SendYourTurnEvent (line 1117) | func (lobby *Lobby) SendYourTurnEvent(player *Player) {
method OnPlayerConnectUnsynchronized (line 1128) | func (lobby *Lobby) OnPlayerConnectUnsynchronized(player *Player) {
method OnPlayerDisconnect (line 1150) | func (lobby *Lobby) OnPlayerDisconnect(player *Player) {
method GetAvailableWordHints (line 1193) | func (lobby *Lobby) GetAvailableWordHints(player *Player) []*WordHint {
method JoinPlayer (line 1206) | func (lobby *Lobby) JoinPlayer(name string) *Player {
method canDraw (line 1227) | func (lobby *Lobby) canDraw(player *Player) bool {
method Shutdown (line 1234) | func (lobby *Lobby) Shutdown() {
type ScoreCalculation (line 1243) | type ScoreCalculation interface
type adjustableScoringAlgorithm (line 1265) | type adjustableScoringAlgorithm struct
method Identifier (line 1273) | func (s *adjustableScoringAlgorithm) Identifier() string {
method CalculateGuesserScore (line 1277) | func (s *adjustableScoringAlgorithm) CalculateGuesserScore(lobby *Lobb...
method MaxScore (line 1281) | func (s *adjustableScoringAlgorithm) MaxScore() int {
method CalculateGuesserScoreInternal (line 1285) | func (s *adjustableScoringAlgorithm) CalculateGuesserScoreInternal(
method CalculateDrawerScore (line 1303) | func (s *adjustableScoringAlgorithm) CalculateDrawerScore(lobby *Lobby...
FILE: internal/game/lobby_test.go
function createLobbyWithDemoPlayers (line 16) | func createLobbyWithDemoPlayers(playercount int) *Lobby {
function noOpWriteObject (line 30) | func noOpWriteObject(_ *Player, _ any) error {
function noOpWritePreparedMessage (line 34) | func noOpWritePreparedMessage(_ *Player, _ *gws.Broadcaster) error {
function Test_Locking (line 38) | func Test_Locking(t *testing.T) {
function Test_CalculateVotesNeededToKick (line 48) | func Test_CalculateVotesNeededToKick(t *testing.T) {
function Test_RemoveAccents (line 74) | func Test_RemoveAccents(t *testing.T) {
function Test_simplifyText (line 106) | func Test_simplifyText(t *testing.T) {
function Test_recalculateRanks (line 144) | func Test_recalculateRanks(t *testing.T) {
function Test_chillScoring_calculateGuesserScore (line 187) | func Test_chillScoring_calculateGuesserScore(t *testing.T) {
function Test_handleNameChangeEvent (line 212) | func Test_handleNameChangeEvent(t *testing.T) {
function getUnexportedField (line 228) | func getUnexportedField(field reflect.Value) any {
function Test_wordSelectionEvent (line 232) | func Test_wordSelectionEvent(t *testing.T) {
function Test_kickDrawer (line 330) | func Test_kickDrawer(t *testing.T) {
function Test_lobby_calculateDrawerScore (line 391) | func Test_lobby_calculateDrawerScore(t *testing.T) {
function Test_NoPrematureGameOver (line 532) | func Test_NoPrematureGameOver(t *testing.T) {
FILE: internal/game/shared.go
constant EventTypeStart (line 16) | EventTypeStart = "start"
constant EventTypeToggleReadiness (line 17) | EventTypeToggleReadiness = "toggle-readiness"
constant EventTypeToggleSpectate (line 18) | EventTypeToggleSpectate = "toggle-spectate"
constant EventTypeRequestDrawing (line 19) | EventTypeRequestDrawing = "request-drawing"
constant EventTypeChooseWord (line 20) | EventTypeChooseWord = "choose-word"
constant EventTypeUndo (line 21) | EventTypeUndo = "undo"
constant EventTypeUpdatePlayers (line 26) | EventTypeUpdatePlayers = "update-players"
constant EventTypeUpdateWordHint (line 27) | EventTypeUpdateWordHint = "update-wordhint"
constant EventTypeWordChosen (line 28) | EventTypeWordChosen = "word-chosen"
constant EventTypeCorrectGuess (line 29) | EventTypeCorrectGuess = "correct-guess"
constant EventTypeCloseGuess (line 30) | EventTypeCloseGuess = "close-guess"
constant EventTypeSystemMessage (line 31) | EventTypeSystemMessage = "system-message"
constant EventTypeNonGuessingPlayerMessage (line 32) | EventTypeNonGuessingPlayerMessage = "non-guessing-player-message"
constant EventTypeReady (line 33) | EventTypeReady = "ready"
constant EventTypeGameOver (line 34) | EventTypeGameOver = "game-over"
constant EventTypeYourTurn (line 35) | EventTypeYourTurn = "your-turn"
constant EventTypeNextTurn (line 36) | EventTypeNextTurn = "next-turn"
constant EventTypeDrawing (line 37) | EventTypeDrawing = "drawing"
constant EventTypeDrawerKicked (line 38) | EventTypeDrawerKicked = "drawer-kicked"
constant EventTypeOwnerChange (line 39) | EventTypeOwnerChange = "owner-change"
constant EventTypeLobbySettingsChanged (line 40) | EventTypeLobbySettingsChanged = "lobby-settings-changed"
constant EventTypeShutdown (line 41) | EventTypeShutdown = "shutdown"
constant EventTypeKeepAlive (line 42) | EventTypeKeepAlive = "keep-alive"
type State (line 55) | type State
constant Unstarted (line 59) | Unstarted State = "unstarted"
constant Ongoing (line 61) | Ongoing State = "ongoing"
constant GameOver (line 64) | GameOver State = "gameOver"
type Event (line 68) | type Event struct
type StringDataEvent (line 73) | type StringDataEvent struct
type EventTypeOnly (line 77) | type EventTypeOnly struct
type IntDataEvent (line 81) | type IntDataEvent struct
type WordHint (line 88) | type WordHint struct
type LineEvent (line 97) | type LineEvent struct
type FillEvent (line 116) | type FillEvent struct
type KickVote (line 131) | type KickVote struct
type OwnerChangeEvent (line 138) | type OwnerChangeEvent struct
type NameChangeEvent (line 143) | type NameChangeEvent struct
type GameOverEvent (line 153) | type GameOverEvent struct
type WordChosen (line 159) | type WordChosen struct
type YourTurn (line 164) | type YourTurn struct
type NextTurn (line 173) | type NextTurn struct
type OutgoingMessage (line 185) | type OutgoingMessage struct
type ReadyEvent (line 197) | type ReadyEvent struct
type Ring (line 212) | type Ring struct
function NewRing (line 218) | func NewRing[T any](cap int) *Ring[T] {
method Push (line 222) | func (r *Ring[T]) Push(v T) {
method Oldest (line 230) | func (r *Ring[T]) Oldest() T {
method Latest (line 238) | func (r *Ring[T]) Latest() T {
type Player (line 248) | type Player struct
type EditableLobbySettings (line 289) | type EditableLobbySettings struct
FILE: internal/game/words.go
type LanguageData (line 16) | type LanguageData struct
function getLanguageIdentifier (line 86) | func getLanguageIdentifier(language string) string {
function readWordListInternal (line 93) | func readWordListInternal(
function readDefaultWordList (line 119) | func readDefaultWordList(lowercaser cases.Caser, chosenLanguage string) ...
function reloadLobbyWords (line 132) | func reloadLobbyWords(lobby *Lobby) ([]string, error) {
function GetRandomWords (line 139) | func GetRandomWords(wordCount int, lobby *Lobby) []string {
function getRandomWords (line 146) | func getRandomWords(wordCount int, lobby *Lobby, reloadWords func(lobby ...
function popCustomWord (line 174) | func popCustomWord(lobby *Lobby) string {
function popWordpackWord (line 184) | func popWordpackWord(lobby *Lobby, reloadWords func(lobby *Lobby) ([]str...
function shuffleWordList (line 201) | func shuffleWordList(wordlist []string) {
constant EqualGuess (line 208) | EqualGuess = 0
constant CloseGuess (line 209) | CloseGuess = 1
constant DistantGuess (line 210) | DistantGuess = 2
function CheckGuess (line 221) | func CheckGuess(a, b string) int {
FILE: internal/game/words_test.go
function Test_wordListsContainNoCarriageReturns (line 15) | func Test_wordListsContainNoCarriageReturns(t *testing.T) {
function Test_readWordList (line 29) | func Test_readWordList(t *testing.T) {
function testWordList (line 50) | func testWordList(t *testing.T, chosenLanguage string) {
function Test_getRandomWords (line 80) | func Test_getRandomWords(t *testing.T) {
function Test_getRandomWordsReloading (line 164) | func Test_getRandomWordsReloading(t *testing.T) {
function Benchmark_proximity_custom (line 248) | func Benchmark_proximity_custom(b *testing.B) {
function Test_CheckGuess_Negative (line 273) | func Test_CheckGuess_Negative(t *testing.T) {
function Test_CheckGuess_Positive (line 353) | func Test_CheckGuess_Positive(t *testing.T) {
FILE: internal/metrics/metrics.go
function init (line 15) | func init() {
function TrackPlayerConnect (line 26) | func TrackPlayerConnect() {
function TrackPlayerDisconnect (line 30) | func TrackPlayerDisconnect() {
function SetupRoute (line 34) | func SetupRoute(registerFunc func(http.HandlerFunc)) {
FILE: internal/sanitize/sanitize.go
function CleanText (line 88) | func CleanText(str string) string {
FILE: internal/state/lobbies.go
function LaunchCleanupRoutine (line 27) | func LaunchCleanupRoutine(cfg config.LobbyCleanup) {
function cleanupRoutineLogic (line 40) | func cleanupRoutineLogic(cfg *config.LobbyCleanup) {
function AddLobby (line 63) | func AddLobby(lobby *game.Lobby) {
function GetLobby (line 72) | func GetLobby(id string) *game.Lobby {
function ShutdownLobbiesGracefully (line 88) | func ShutdownLobbiesGracefully() {
function GetActiveLobbyCount (line 107) | func GetActiveLobbyCount() int {
function GetPublicLobbies (line 117) | func GetPublicLobbies() []*game.Lobby {
function RemoveLobby (line 132) | func RemoveLobby(id string) {
function removeLobby (line 139) | func removeLobby(id string) {
function removeLobbyByIndex (line 148) | func removeLobbyByIndex(index int) {
type PageStats (line 161) | type PageStats struct
function Stats (line 170) | func Stats() *PageStats {
FILE: internal/state/lobbies_test.go
function TestAddAndRemove (line 12) | func TestAddAndRemove(t *testing.T) {
FILE: internal/translations/ar.go
function initArabicTranslation (line 3) | func initArabicTranslation() *Translation {
FILE: internal/translations/de_DE.go
function initGermanTranslation (line 3) | func initGermanTranslation() {
FILE: internal/translations/en_us.go
function initEnglishTranslation (line 3) | func initEnglishTranslation() *Translation {
FILE: internal/translations/es_ES.go
function initSpainTranslation (line 3) | func initSpainTranslation() {
FILE: internal/translations/fa.go
function initPersianTranslation (line 3) | func initPersianTranslation() *Translation {
FILE: internal/translations/fr_FR.go
function initFrenchTranslation (line 3) | func initFrenchTranslation() *Translation {
FILE: internal/translations/he.go
function initHebrewTranslation (line 3) | func initHebrewTranslation() {
FILE: internal/translations/pl.go
function initPolishTranslation (line 3) | func initPolishTranslation() {
FILE: internal/translations/translations.go
function init (line 12) | func init() {
type Translation (line 33) | type Translation struct
method Get (line 40) | func (translation Translation) Get(key string) string {
method put (line 56) | func (translation Translation) put(key, value string) {
function GetLanguage (line 75) | func GetLanguage(locale string) *Translation {
function RegisterTranslation (line 82) | func RegisterTranslation(locale string, translation *Translation) {
function createTranslation (line 105) | func createTranslation() *Translation {
FILE: internal/translations/translations_test.go
function Test_noObsoleteKeys (line 10) | func Test_noObsoleteKeys(t *testing.T) {
FILE: internal/version/version.go
function init (line 15) | func init() {
FILE: tools/sanitizer/main.go
function main (line 15) | func main() {
FILE: tools/simulate/main.go
type body (line 19) | type body struct
function request (line 24) | func request(method, url string, body *body, queryParameters map[string]...
function PostLobby (line 50) | func PostLobby() (*api.LobbyData, error) {
type SimPlayer (line 80) | type SimPlayer struct
method WriteJSON (line 87) | func (s *SimPlayer) WriteJSON(value any) error {
method SendRandomStroke (line 96) | func (s *SimPlayer) SendRandomStroke() {
method SendRandomMessage (line 113) | func (s *SimPlayer) SendRandomMessage() {
function JoinPlayer (line 122) | func JoinPlayer(lobbyId string) (*SimPlayer, error) {
function main (line 176) | func main() {
FILE: tools/skribbliohintsconverter/main.go
function main (line 10) | func main() {
FILE: tools/statcollector/main.go
type Stat (line 13) | type Stat struct
function main (line 18) | func main() {
Condensed preview — 92 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,457K chars).
[
{
"path": ".dockerignore",
"chars": 502,
"preview": "# We are going with a whitelisting approach, to avoid accidentally bleeding\n# too much into our container. This reduces "
},
{
"path": ".gitattributes",
"chars": 57,
"preview": "* text eol=lf\n*.otf binary\n*.png binary \n*.wav binary"
},
{
"path": ".github/workflows/docker-image-update.yml",
"chars": 1438,
"preview": "name: docker image update\n\non:\n workflow_run:\n workflows: [Build]\n branches: [v**]\n types: [completed]\n\njobs:\n"
},
{
"path": ".github/workflows/release.yml",
"chars": 1353,
"preview": "name: Publish release\n\non:\n workflow_run:\n workflows: [Build]\n branches: [v**]\n types: [completed]\n\njobs:\n pu"
},
{
"path": ".github/workflows/test-and-build.yml",
"chars": 1510,
"preview": "name: Build\n\non: push\n\njobs:\n test-and-build:\n strategy:\n matrix:\n include:\n - platform: window"
},
{
"path": ".github/workflows/test-pr.yml",
"chars": 461,
"preview": "name: Run tests\n\non: pull_request\n\njobs:\n run-tests:\n strategy:\n matrix:\n go-version: [1.25.5]\n p"
},
{
"path": ".gitignore",
"chars": 358,
"preview": "# Executable names\n/scribblers\n*.exe\n__debug_bin\n# IntelliJ state\n.idea/\n# Folder that contains code used for trying stu"
},
{
"path": ".golangci.yml",
"chars": 2709,
"preview": "linters:\n enable-all: true\n disable:\n ## These are deprecated\n - exportloopref\n\n ## These are too strict for "
},
{
"path": ".vscode/launch.json",
"chars": 510,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n"
},
{
"path": ".vscode/settings.json",
"chars": 167,
"preview": "{\n \"html.format.templating\": true,\n \"go.lintTool\": \"golangci-lint\",\n \"go.useLanguageServer\": true,\n \"gopls\":"
},
{
"path": "LICENSE",
"chars": 1519,
"preview": "BSD 3-Clause License\n\nCopyright (c) 2019, scribble-rs\nAll rights reserved.\n\nRedistribution and use in source and binary "
},
{
"path": "README.md",
"chars": 8901,
"preview": "<h1 align=\"center\">Scribble.rs</h1>\n\n<p align=\"center\">\n <a href=\"https://discord.gg/cE5BKP2UnE\"><img src=\"https://dcba"
},
{
"path": "cmd/scribblers/main.go",
"chars": 2793,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path\"\n\t\"runtime/pprof\"\n\t\"strings\"\n\t\"syscall\"\n\t\"tim"
},
{
"path": "fly.Dockerfile",
"chars": 1345,
"preview": "#\n# Builder for Golang\n#\n# We explicitly use a certain major version of go, to make sure we don't build\n# with a newer v"
},
{
"path": "fly.toml",
"chars": 691,
"preview": "# See https://fly.io/docs/reference/configuration/\n\napp = \"scribblers\"\n# \"fra\" is only for paying customers\nprimary_regi"
},
{
"path": "fly_deploy.sh",
"chars": 68,
"preview": "#!/bin/sh\nflyctl deploy --build-arg \"VERSION=$(git describe --tag)\"\n"
},
{
"path": "go.mod",
"chars": 1119,
"preview": "module github.com/scribble-rs/scribble.rs\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/Bios-Marcel/discordemojimap/v2 v2.0.6\n\tgithu"
},
{
"path": "go.sum",
"chars": 5410,
"preview": "github.com/Bios-Marcel/discordemojimap/v2 v2.0.6 h1:VjNAT59riXBTKeKEqVb83irOZJ52a9qVy9HxzlL7C04=\ngithub.com/Bios-Marcel/"
},
{
"path": "internal/api/createparse.go",
"chars": 5573,
"preview": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/config\"\n\t\"gi"
},
{
"path": "internal/api/createparse_test.go",
"chars": 7397,
"preview": "package api\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/config\"\n\t\"github.com/scribble"
},
{
"path": "internal/api/doc.go",
"chars": 355,
"preview": "// Package api the public APIs for both the HTTP and the WS endpoints.\n// These are being used by the official client an"
},
{
"path": "internal/api/http.go",
"chars": 3552,
"preview": "package api\n\nimport (\n\t\"net/http\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/metrics\"\n)\n\n// Setup"
},
{
"path": "internal/api/v1.go",
"chars": 19619,
"preview": "// This file contains the API methods for the public API\n\npackage api\n\nimport (\n\tjson \"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t"
},
{
"path": "internal/api/ws.go",
"chars": 5213,
"preview": "package api\n\nimport (\n\tjson \"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"runtime/debug\"\n\t\"time\"\n\n\t\"github.com/g"
},
{
"path": "internal/config/config.go",
"chars": 5784,
"preview": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"maps\"\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\t\"git"
},
{
"path": "internal/frontend/doc.go",
"chars": 173,
"preview": "// Package frontend contains the HTTP endpoints for delivering the official\n// web client. In order to register the endp"
},
{
"path": "internal/frontend/http.go",
"chars": 6388,
"preview": "package frontend\n\nimport (\n\t\"embed\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"hash\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"strings\""
},
{
"path": "internal/frontend/index.go",
"chars": 11191,
"preview": "package frontend\n\nimport (\n\t//nolint:gosec //We just use this for cache busting, so it's secure enough\n\n\t\"crypto/md5\"\n\t\""
},
{
"path": "internal/frontend/index.js",
"chars": 8251,
"preview": "const discordInstanceId = getCookie(\"discord-instance-id\");\nconst rootPath = `${discordInstanceId ? \".proxy/\" : \"\"}{{.Ro"
},
{
"path": "internal/frontend/lobby.go",
"chars": 4602,
"preview": "package frontend\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/api\"\n\t\"github.co"
},
{
"path": "internal/frontend/lobby.js",
"chars": 71546,
"preview": "String.prototype.format = function () {\n return [...arguments].reduce((p, c) => p.replace(/%s/, c), this);\n};\n\nconst "
},
{
"path": "internal/frontend/lobby_test.go",
"chars": 873,
"preview": "package frontend\n\nimport (\n\t\"testing\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/api\"\n\t\"github.com/scribble-rs/scrib"
},
{
"path": "internal/frontend/resources/draw.js",
"chars": 10109,
"preview": "//Notice for core code of the floodfill, which has since then been heavily\n//changed.\n//Copyright(c) Max Irwin - 2011, 2"
},
{
"path": "internal/frontend/resources/error.css",
"chars": 513,
"preview": ".error-pane-wrapper {\n flex: 1;\n display: flex;\n flex-direction: column;\n}\n\n.error-pane {\n display: inline-b"
},
{
"path": "internal/frontend/resources/index.css",
"chars": 8609,
"preview": "* {\n border: none;\n margin: 0;\n}\n\n/*root end*/\n\n#logo {\n width: 50vw;\n margin-top: 1rem;\n margin-bottom: "
},
{
"path": "internal/frontend/resources/lobby.css",
"chars": 15025,
"preview": ":root {\n --dot-color: black;\n}\n\n.noscript {\n display: flex;\n font-size: 2.5rem;\n font-weight: bold;\n just"
},
{
"path": "internal/frontend/resources/root.css",
"chars": 2620,
"preview": ":root {\n --pane-background: #eee5e9;\n --component-base-color: #ffffff;\n --component-hover-background: rgb(250, "
},
{
"path": "internal/frontend/templates/error.html",
"chars": 984,
"preview": "{{define \"error-page\"}}\n<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n <title>Scribble.rs - Error</title>\n <meta charse"
},
{
"path": "internal/frontend/templates/favicon.html",
"chars": 497,
"preview": "{{define \"favicon-decl\"}}\n<link rel=\"icon\" type=\"image/svg+xml\" href='{{.RootPath}}/resources/{{.WithCacheBust \"favicon."
},
{
"path": "internal/frontend/templates/footer.html",
"chars": 1013,
"preview": "{{define \"footer\"}}\n{{if eq .Version \"dev\"}}\n<a class=\"footer-item\" href=\"https://github.com/scribble-rs/scribble.rs\" ta"
},
{
"path": "internal/frontend/templates/index.html",
"chars": 11875,
"preview": "{{define \"index\"}}\n<!DOCTYPE html>\n<html lang=\"{{.Locale}}\">\n\n<head>\n <title>Scribble.rs</title>\n <meta charset=\"U"
},
{
"path": "internal/frontend/templates/lobby.html",
"chars": 27281,
"preview": "{{define \"lobby-page\"}}\n<!DOCTYPE html>\n<html lang=\"{{.Locale}}\">\n\n<head>\n <title>Scribble.rs - Game</title>\n <met"
},
{
"path": "internal/frontend/templates/non-static-css.html",
"chars": 344,
"preview": "{{/* A little hack so we need don't to have CSS templates and embed those directly. */}}\n{{/* The reason being that we n"
},
{
"path": "internal/frontend/templating_test.go",
"chars": 1532,
"preview": "package frontend\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/api\"\n\t\"github.com/scribble"
},
{
"path": "internal/game/data.go",
"chars": 9658,
"preview": "package game\n\nimport (\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tdiscordemojimap \"github.com/Bios-Marcel/discordemojimap/v2\"\n\t\"github"
},
{
"path": "internal/game/data_test.go",
"chars": 1438,
"preview": "package game\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestOccupiedPlayerCount(t *testing.T) {\n\tt.Parallel()\n\n\tlobby := &Lobb"
},
{
"path": "internal/game/lobby.go",
"chars": 41315,
"preview": "package game\n\nimport (\n\tjson \"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"math/rand/v2\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n"
},
{
"path": "internal/game/lobby_test.go",
"chars": 13072,
"preview": "package game\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\t\"unsafe\"\n\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.co"
},
{
"path": "internal/game/shared.go",
"chars": 10875,
"preview": "package game\n\nimport (\n\t\"time\"\n\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/lxzan/gws\"\n)\n\n//\n// This file contains all stru"
},
{
"path": "internal/game/words/ar",
"chars": 1620,
"preview": "آيس كريم\nأرز\nأرنب\nأسد\nأكل\nأناناس\nإبريق\nإسفنجة\nإطار\nالأهرامات\nالبيت الأبيض\nباب\nباتمان\nباذنجان\nبازلاء\nببغاء\nبرتقال\nبرج\nبرج"
},
{
"path": "internal/game/words/de",
"chars": 47842,
"preview": "abdeckung\nabend\nabendessen\nabenteuer\nabfahrt\nabfall\nabfliegen\nabflussrohr\nabgrund\nabhalten\nabheben\nabhilfe\nabhängen\nabhä"
},
{
"path": "internal/game/words/en_gb",
"chars": 39389,
"preview": "abandon\nabbey\nability\nable\nabnormal\nabolish\nabortion\nabraham lincoln\nabridge\nabsence\nabsent\nabsolute\nabsorb\nabsorption\na"
},
{
"path": "internal/game/words/en_us",
"chars": 39423,
"preview": "abandon\nabbey\nability\nable\nabnormal\nabolish\nabortion\nabraham lincoln\nabridge\nabsence\nabsent\nabsolute\nabsorb\nabsorption\na"
},
{
"path": "internal/game/words/fa",
"chars": 35747,
"preview": "آئودی\nآب\nآب دهان\nآب شدن\nآب شور\nآبپاش\nآبجو\nآبخوری پرنده\nآبراهام لینکلن\nآبشار\nآبفشان\nآبمیوه\nآبنبات چوبی\nآبی\nآپارتمان\nآ"
},
{
"path": "internal/game/words/fr",
"chars": 14607,
"preview": "capacité\navortement\nabus\nacadémie\naccident\ncomptable\nacide\nacte\najouter\naccro\najout\nadresse\nadministrateur\nadulte\npublic"
},
{
"path": "internal/game/words/he",
"chars": 31338,
"preview": "לנטוש\nמנזר\nיכולת\nמסוגל\nלא שגרתי\nלבטל\nהפלה\nאברהם לינקולן\nלקצר\nהיעדרות\nנעדר\nמוחלט\nלספוג\nספיגה\nמופשט\nשופע\nלנצל\nתהום\nזרם ישר"
},
{
"path": "internal/game/words/it",
"chars": 16816,
"preview": "capacità\naborto\nabuso\naccademia\nincidente\ncontabile\nacido\natto\ninserisci\ndipendente\naggiunta\nindirizzo\namministratore\nad"
},
{
"path": "internal/game/words/nl",
"chars": 13943,
"preview": "aanbieding\naandrijving\naangename\naankondiging\naantrekken\naanval\naanwezig\naanwijzing\naap\naardappel\naardbei\naardbeving\naar"
},
{
"path": "internal/game/words/pl",
"chars": 34038,
"preview": "Abraham Lincoln\nabsolutny\nabsolwent\nabstrakcyjny\nac/dc\nadidas\nadministracja\nadministrator\nadopcja\nadoptować\nadres\nAfryka"
},
{
"path": "internal/game/words/ru",
"chars": 24773,
"preview": "абзац\nабонент\nавария\nавиакомпания\nавиация\nавтобус\nавтограф\nавтомат\nавтомашина\nавторитет\nагент\nагентство\nагрегат\nагрессия"
},
{
"path": "internal/game/words/ua",
"chars": 2786,
"preview": "абрикос\nавтобус\nавтомобіль\nадвокат\nаеропорт\nактор\nананас\nапельсин\nаптека\nбагаж\nбагажник\nбаклажан\nбалкон\nбанан\nбанк\nбатар"
},
{
"path": "internal/game/words.go",
"chars": 9031,
"preview": "package game\n\nimport (\n\t\"embed\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand/v2\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"golang.org/x/text/"
},
{
"path": "internal/game/words_test.go",
"chars": 8863,
"preview": "package game\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"golang.o"
},
{
"path": "internal/metrics/metrics.go",
"chars": 785,
"preview": "package metrics\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_"
},
{
"path": "internal/sanitize/sanitize.go",
"chars": 1937,
"preview": "// Package sanitize is used for cleaning up text.\npackage sanitize\n\nimport (\n\t\"unicode/utf8\"\n)\n\n// FIXME Improve transli"
},
{
"path": "internal/state/doc.go",
"chars": 364,
"preview": "// Package state provides the application state. Currently this is only the\n// open lobbies. However, the lobby state it"
},
{
"path": "internal/state/lobbies.go",
"chars": 5761,
"preview": "package state\n\nimport (\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/config\"\n\t\"github.com/scrib"
},
{
"path": "internal/state/lobbies_test.go",
"chars": 1421,
"preview": "package state\n\nimport (\n\t\"testing\"\n\n\t\"github.com/scribble-rs/scribble.rs/internal/config\"\n\t\"github.com/scribble-rs/scrib"
},
{
"path": "internal/translations/ar.go",
"chars": 6749,
"preview": "package translations\n\nfunc initArabicTranslation() *Translation {\n\ttranslation := createTranslation()\n\ttranslation.IsRtl"
},
{
"path": "internal/translations/de_DE.go",
"chars": 7199,
"preview": "package translations\n\nfunc initGermanTranslation() {\n\ttranslation := createTranslation()\n\n\ttranslation.put(\"requires-js\""
},
{
"path": "internal/translations/doc.go",
"chars": 626,
"preview": "/*\nPackage translations introduces a simple localization layer that can be\nused by a templating engine.\n\nUpon retrieving"
},
{
"path": "internal/translations/en_us.go",
"chars": 8592,
"preview": "package translations\n\nfunc initEnglishTranslation() *Translation {\n\ttranslation := createTranslation()\n\n\ttranslation.put"
},
{
"path": "internal/translations/es_ES.go",
"chars": 7748,
"preview": "package translations\n\nfunc initSpainTranslation() {\n\ttranslation := createTranslation()\n\n\ttranslation.put(\"requires-js\","
},
{
"path": "internal/translations/fa.go",
"chars": 8376,
"preview": "package translations\n\nfunc initPersianTranslation() *Translation {\n\ttranslation := createTranslation()\n\ttranslation.IsRt"
},
{
"path": "internal/translations/fr_FR.go",
"chars": 9074,
"preview": "package translations\n\nfunc initFrenchTranslation() *Translation {\n\ttranslation := createTranslation()\n\n\ttranslation.put("
},
{
"path": "internal/translations/he.go",
"chars": 7620,
"preview": "package translations\n\nfunc initHebrewTranslation() {\n\ttranslation := createTranslation()\n\ttranslation.IsRtl = true\n\n\ttra"
},
{
"path": "internal/translations/pl.go",
"chars": 6696,
"preview": "package translations\n\nfunc initPolishTranslation() {\n\ttranslation := createTranslation()\n\n\ttranslation.put(\"requires-js\""
},
{
"path": "internal/translations/translations.go",
"chars": 3172,
"preview": "package translations\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"golang.org/x/text/language\"\n)\n\n// init initializes all localization "
},
{
"path": "internal/translations/translations_test.go",
"chars": 464,
"preview": "package translations\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_noObsoleteKeys(t *t"
},
{
"path": "internal/version/version.go",
"chars": 664,
"preview": "// version holds the git that this version was built from. In development\n// scearios, it will default to \"dev\".\npackage"
},
{
"path": "linux.Dockerfile",
"chars": 1296,
"preview": "#\n# Builder for Golang\n#\n# We explicitly use a certain major version of go, to make sure we don't build\n# with a newer v"
},
{
"path": "tools/compare_en_words.sh",
"chars": 560,
"preview": "#!/usr/bin/env bash\n\nUS_DICT=\"../game/words/en_us\"\n\n# For example on install british dict\n# apt-get install wbritish-lar"
},
{
"path": "tools/sanitizer/README.md",
"chars": 262,
"preview": "# Sanitizer\n\nThis tool lowercases, deduplicates, sorts and cleans the word lists.\n\nFirst argument is expected to be the "
},
{
"path": "tools/sanitizer/main.go",
"chars": 1254,
"preview": "package main\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/la"
},
{
"path": "tools/simulate/main.go",
"chars": 4341,
"preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"math/rand/v2\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\""
},
{
"path": "tools/skribbliohintsconverter/README.md",
"chars": 189,
"preview": "# Skribblio Hints Converter\n\nConverts lists from https://github.com/skribbliohints/skribbliohints.github.io into plain n"
},
{
"path": "tools/skribbliohintsconverter/english.json",
"chars": 292053,
"preview": "{\"bear\":{\"count\":227,\"lastSeenTime\":1577025649999,\"publicGameCount\":5,\"difficulty\":0.4492753623188406,\"difficultyWeight\""
},
{
"path": "tools/skribbliohintsconverter/german.json",
"chars": 200261,
"preview": "{\"Sommersprossen\":{\"count\":5,\"lastSeenTime\":1586959168567},\"inkognito\":{\"count\":7,\"lastSeenTime\":1586901586361},\"Marienk"
},
{
"path": "tools/skribbliohintsconverter/main.go",
"chars": 396,
"preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n)\n\nfunc main() {\n\tlanguageFile, err := os.Open(os.Args[len(os"
},
{
"path": "tools/statcollector/main.go",
"chars": 1429,
"preview": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"flag\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n)\n\ntype Stat struct {\n\tTime time."
},
{
"path": "tools/translate.sh",
"chars": 1307,
"preview": "#!/usr/bin/env bash\n\nif [ -z ${FROM+x} ]; then echo \"FROM is unset\"; exit 1; fi\nif [ -z ${TO+x} ]; then echo \"TO is unse"
},
{
"path": "windows.Dockerfile",
"chars": 1039,
"preview": "#\n# Builder for Golang\n#\n# We explicitly use a certain major version of go, to make sure we don't build\n# with a newer v"
}
]
About this extraction
This page contains the full source code of the scribble-rs/scribble.rs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 92 files (1.2 MB), approximately 401.9k tokens, and a symbol index with 408 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.