├── tests ├── .gitignore ├── js │ └── talk-to-ports.test.js ├── Main.elm ├── Tests.elm ├── Tests │ ├── Utils.elm │ └── Data │ │ ├── Player.elm │ │ ├── OutgoingMessage.elm │ │ └── Room.elm └── elm-package.json ├── examples ├── fast-and-moebius │ ├── README.md │ ├── index.html │ └── elm-package.json ├── lettero │ ├── index.html │ ├── elm-package.json │ └── Main.elm ├── the-capitalist │ ├── index.html │ ├── elm-package.json │ └── Main.elm ├── spacecraterball │ ├── index.html │ └── elm-package.json └── counterclockwooze │ ├── index.html │ ├── elm-package.json │ └── Main.elm ├── bin ├── lint-js.sh ├── test-js.sh ├── example.sh ├── run-webrtc-test-server.js └── site.sh ├── site ├── .firebaserc ├── database.rules.json ├── firebase.json ├── README.md ├── src │ ├── index.html │ ├── index.js │ ├── Styles.elm │ └── Main.elm └── elm-package.json ├── src ├── Data │ ├── RoundResult.elm │ ├── Ports.elm │ ├── Route.elm │ ├── Guess.elm │ ├── Round.elm │ ├── IncomingMessage.elm │ ├── RoundTime.elm │ ├── OutgoingMessage.elm │ ├── Player.elm │ ├── Spec.elm │ └── Room.elm ├── Views │ ├── Loader.elm │ ├── Footer.elm │ ├── Header.elm │ ├── Link.elm │ ├── Attribution.elm │ ├── NoMultiplayer.elm │ ├── Timer.elm │ ├── NoMultiplayer │ │ └── Styles.elm │ ├── Scoreboard.elm │ ├── Footer │ │ └── Styles.elm │ ├── Timer │ │ └── Styles.elm │ ├── Notification.elm │ ├── Scoreboard │ │ └── Styles.elm │ ├── Header │ │ └── Styles.elm │ ├── Attribution │ │ └── Styles.elm │ ├── Notification │ │ └── Styles.elm │ ├── Layout.elm │ └── Logo.elm ├── Models.elm ├── Page │ ├── Tutorial │ │ ├── Messages.elm │ │ ├── Models.elm │ │ ├── Update.elm │ │ ├── Views │ │ │ └── Styles.elm │ │ └── Views.elm │ ├── NewRoom │ │ ├── Messages.elm │ │ ├── Models.elm │ │ ├── Views │ │ │ └── Styles.elm │ │ ├── Update.elm │ │ └── Views.elm │ ├── Game │ │ ├── Messages.elm │ │ ├── Views │ │ │ └── Styles.elm │ │ ├── Helpers.elm │ │ ├── Models.elm │ │ ├── Update.elm │ │ └── Views.elm │ ├── NotFound │ │ ├── Views.elm │ │ └── Views │ │ │ └── Styles.elm │ └── Home │ │ ├── Views.elm │ │ └── Views │ │ └── Styles.elm ├── Utils.elm ├── Messages.elm ├── Styles │ ├── Constants.elm │ ├── Shared.elm │ └── Mixins.elm ├── Copy.elm ├── Gameroom │ └── Context.elm ├── js │ ├── db │ │ ├── firebase.js │ │ ├── local-storage.js │ │ └── webrtc.js │ ├── talk-to-ports.js │ └── README.md ├── Subscriptions.elm ├── Router.elm ├── Views.elm ├── Update.elm └── Gameroom.elm ├── .gitignore ├── elm-package.json ├── LICENSE └── README.md /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff/ 2 | -------------------------------------------------------------------------------- /examples/fast-and-moebius/README.md: -------------------------------------------------------------------------------- 1 | Incomplete example :). 2 | -------------------------------------------------------------------------------- /bin/lint-js.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | standard src/**/*.js --fix 4 | -------------------------------------------------------------------------------- /bin/test-js.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | jest tests/**/*.js --notify 4 | -------------------------------------------------------------------------------- /site/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "elm-gameroom" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /site/database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": true, 4 | ".write": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Data/RoundResult.elm: -------------------------------------------------------------------------------- 1 | module Data.RoundResult exposing (..) 2 | 3 | 4 | type RoundResult 5 | = Pending 6 | | Winner String 7 | | Tie 8 | -------------------------------------------------------------------------------- /src/Views/Loader.elm: -------------------------------------------------------------------------------- 1 | module Views.Loader exposing (view) 2 | 3 | import Html exposing (Html, text) 4 | 5 | 6 | view : Html msg 7 | view = 8 | text "Loading.." 9 | -------------------------------------------------------------------------------- /tests/js/talk-to-ports.test.js: -------------------------------------------------------------------------------- 1 | const talkToPorts = require('../../src/js/talk-to-ports.js') 2 | 3 | test('talkToPorts is a function', () => { 4 | expect(typeof talkToPorts).toBe('function') 5 | }) 6 | -------------------------------------------------------------------------------- /src/Models.elm: -------------------------------------------------------------------------------- 1 | module Models exposing (..) 2 | 3 | import Data.Route exposing (Route) 4 | import Window 5 | 6 | 7 | type alias Model problem guess = 8 | { route : Route problem guess 9 | , windowSize : Window.Size 10 | } 11 | -------------------------------------------------------------------------------- /src/Page/Tutorial/Messages.elm: -------------------------------------------------------------------------------- 1 | module Page.Tutorial.Messages exposing (..) 2 | 3 | import Time 4 | 5 | 6 | type Msg problem guess 7 | = RequestNewProblem 8 | | ReceiveProblem problem 9 | | Guess guess 10 | | Tick Time.Time 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | node_modules 3 | repl-temp-* 4 | .DS_Store 5 | .env 6 | examples/**/elm.js 7 | examples/**/talk-to-ports.js 8 | examples/**/db 9 | examples/**/200.html 10 | site/dist 11 | site/src/home.js 12 | documentation.json 13 | tmp 14 | *.log 15 | -------------------------------------------------------------------------------- /bin/example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Copy JS boilerplate 4 | cp -r src/js/db examples/$1 5 | cp src/js/talk-to-ports.js examples/$1 6 | 7 | # Start elm-live server 8 | cd examples/$1 9 | elm-live Main.elm --dir=. --output elm.js --debug --open --pushstate 10 | -------------------------------------------------------------------------------- /site/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": { 6 | "public": "dist", 7 | "rewrites": [ 8 | { 9 | "source": "**", 10 | "destination": "/index.html" 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Views/Footer.elm: -------------------------------------------------------------------------------- 1 | module Views.Footer exposing (view) 2 | 3 | import Html exposing (Html, div, footer) 4 | import Views.Footer.Styles exposing (CssClasses(..), localClass) 5 | 6 | 7 | view : List (Html msg) -> Html msg 8 | view children = 9 | div [ localClass [ Root ] ] children 10 | -------------------------------------------------------------------------------- /tests/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Tests 4 | import Test.Runner.Node exposing (run, TestProgram) 5 | import Json.Encode exposing (Value) 6 | 7 | 8 | main : TestProgram 9 | main = 10 | run emit Tests.all 11 | 12 | 13 | port emit : ( String, Value ) -> Cmd msg 14 | -------------------------------------------------------------------------------- /src/Page/NewRoom/Messages.elm: -------------------------------------------------------------------------------- 1 | module Page.NewRoom.Messages exposing (..) 2 | 3 | 4 | type Msg 5 | = ChangeRoomId String 6 | | ChangePlayerId Int String 7 | | AddPlayer 8 | | CreateRequest 9 | | CreateResponse String 10 | | RemovePlayer Int 11 | | DismissUrlizeNotification 12 | -------------------------------------------------------------------------------- /src/Page/Game/Messages.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Messages exposing (..) 2 | 3 | import Time 4 | import Data.Room as Room 5 | 6 | 7 | type Msg problem guess 8 | = Guess guess 9 | | Tick Time.Time 10 | | ReceiveUpdate (Room.Room problem guess) 11 | | MarkReady 12 | | ReceiveNewProblem problem 13 | -------------------------------------------------------------------------------- /src/Data/Ports.elm: -------------------------------------------------------------------------------- 1 | module Data.Ports exposing (..) 2 | 3 | import Json.Encode as JE 4 | 5 | 6 | type alias Ports msg = 7 | { incoming : (JE.Value -> msg) -> Sub msg 8 | , outgoing : JE.Value -> Cmd msg 9 | } 10 | 11 | 12 | init : Ports msg 13 | init = 14 | { incoming = always Sub.none 15 | , outgoing = always Cmd.none 16 | } 17 | -------------------------------------------------------------------------------- /src/Data/Route.elm: -------------------------------------------------------------------------------- 1 | module Data.Route exposing (..) 2 | 3 | import Page.NewRoom.Models 4 | import Page.Game.Models 5 | import Page.Tutorial.Models 6 | 7 | 8 | type Route problem guess 9 | = Home 10 | | NewRoom Page.NewRoom.Models.Model 11 | | Tutorial (Page.Tutorial.Models.Model problem guess) 12 | | Game (Page.Game.Models.Model problem guess) 13 | | NotOnBaseRoute 14 | | NotFound 15 | -------------------------------------------------------------------------------- /src/Data/Guess.elm: -------------------------------------------------------------------------------- 1 | module Data.Guess exposing (..) 2 | 3 | import Time 4 | import Json.Decode as JD 5 | 6 | 7 | type alias Guess guess = 8 | { value : guess 9 | , madeAt : Time.Time 10 | } 11 | 12 | 13 | decoder : JD.Decoder guess -> JD.Decoder (Guess guess) 14 | decoder guessDecoder = 15 | JD.map2 Guess 16 | (JD.field "value" guessDecoder) 17 | (JD.field "madeAt" JD.float) 18 | -------------------------------------------------------------------------------- /src/Page/Tutorial/Models.elm: -------------------------------------------------------------------------------- 1 | module Page.Tutorial.Models exposing (..) 2 | 3 | import Data.RoundTime as RoundTime 4 | 5 | 6 | type alias Model problem guess = 7 | { problem : Maybe problem 8 | , guess : Maybe guess 9 | , time : RoundTime.RoundTime 10 | } 11 | 12 | 13 | init : Model problem guess 14 | init = 15 | { problem = Nothing 16 | , guess = Nothing 17 | , time = RoundTime.init 18 | } 19 | -------------------------------------------------------------------------------- /tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Tests exposing (..) 2 | 3 | import Test exposing (..) 4 | import Tests.Utils 5 | import Tests.Data.Room 6 | import Tests.Data.Player 7 | import Tests.Data.OutgoingMessage 8 | 9 | 10 | all : Test 11 | all = 12 | describe "elm-gameroom" 13 | [ Tests.Utils.tests 14 | , Tests.Data.Room.tests 15 | , Tests.Data.Player.tests 16 | , Tests.Data.OutgoingMessage.tests 17 | ] 18 | -------------------------------------------------------------------------------- /src/Views/Header.elm: -------------------------------------------------------------------------------- 1 | module Views.Header exposing (view) 2 | 3 | import Html exposing (Html, header, text) 4 | import Messages exposing (Msg) 5 | import Views.Link as Link 6 | import Views.Header.Styles exposing (CssClasses(..), localClass) 7 | 8 | 9 | view : String -> Html (Msg problem guess) 10 | view icon = 11 | header [ localClass [ Root ] ] 12 | [ Link.view "/" [ localClass [ HomeLink ] ] [ text icon ] 13 | ] 14 | -------------------------------------------------------------------------------- /src/Page/NewRoom/Models.elm: -------------------------------------------------------------------------------- 1 | module Page.NewRoom.Models exposing (..) 2 | 3 | 4 | type Status 5 | = Editing 6 | | Pending 7 | | Error 8 | 9 | 10 | type alias Model = 11 | { roomId : String 12 | , playerIds : List String 13 | , status : Status 14 | , entriesUrlized : Bool 15 | , isUrlizedNotificationDismissed : Bool 16 | } 17 | 18 | 19 | init : Model 20 | init = 21 | Model "" [ "", "" ] Editing False False 22 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | This folder defines the content of the [demo site](https://elm-gameroom.firebaseapp.io), requiring all kinds of files from the rest of the repository, e.g. example games. 2 | 3 | To deploy, make sure you have `uglifyjs` installed: `npm i uglify-js -g`. Then run `./bin/site.sh build` from the repository's root, add firebase credentials manually to the distribution folder (follow instructions from the previous shell script), and run `./bin/site.sh deploy` to deploy. 4 | -------------------------------------------------------------------------------- /src/Utils.elm: -------------------------------------------------------------------------------- 1 | module Utils exposing (trimspaces, urlize, template) 2 | 3 | import Regex exposing (..) 4 | 5 | 6 | trimspaces : String -> String 7 | trimspaces = 8 | replace All (regex " ") (\_ -> "-") 9 | 10 | 11 | urlize : String -> String 12 | urlize = 13 | trimspaces << String.toLower 14 | 15 | 16 | template : String -> String -> String 17 | template templ value = 18 | replace 19 | All 20 | ("${}" |> escape |> regex) 21 | (\_ -> value) 22 | templ 23 | -------------------------------------------------------------------------------- /src/Views/Link.elm: -------------------------------------------------------------------------------- 1 | module Views.Link exposing (view) 2 | 3 | import Html exposing (Html, a) 4 | import Html.Events exposing (onClick) 5 | import Messages exposing (Msg(Navigate)) 6 | 7 | 8 | view : 9 | String 10 | -> List (Html.Attribute (Msg problem guess)) 11 | -> List (Html (Msg problem guess)) 12 | -> Html (Msg problem guess) 13 | view url attrs children = 14 | a 15 | (attrs 16 | ++ [ onClick (Navigate url) 17 | ] 18 | ) 19 | children 20 | -------------------------------------------------------------------------------- /src/Views/Attribution.elm: -------------------------------------------------------------------------------- 1 | module Views.Attribution exposing (view) 2 | 3 | import Html exposing (Html, div, p, a, span, text) 4 | import Html.Attributes exposing (href) 5 | import Views.Attribution.Styles exposing (CssClasses(..), localClass) 6 | import Views.Logo 7 | 8 | 9 | view : Html msg 10 | view = 11 | a [ localClass [ Root ], href "https://elm-gameroom.firebaseapp.com" ] 12 | [ span [] [ text "Powered by " ] 13 | , Views.Logo.view 14 | , span [] [ text " elm-gameroom" ] 15 | ] 16 | -------------------------------------------------------------------------------- /src/Views/NoMultiplayer.elm: -------------------------------------------------------------------------------- 1 | module Views.NoMultiplayer exposing (view) 2 | 3 | import Html exposing (Html, div, h1, p, text) 4 | import Views.NoMultiplayer.Styles exposing (CssClasses(..), localClass) 5 | 6 | 7 | view : Html msg 8 | view = 9 | div [ localClass [ Root ] ] 10 | [ h1 [] [ text "Multiplayer not yet working" ] 11 | , p [] [ text "You need to do some extra setup work for multiplayer to work on this game. Have a look at the elm-gameroom docs for instructions on how to proceed." ] 12 | ] 13 | -------------------------------------------------------------------------------- /src/Page/NotFound/Views.elm: -------------------------------------------------------------------------------- 1 | module Page.NotFound.Views exposing (view) 2 | 3 | import Html exposing (Html, div, h2, p, a, text) 4 | import Messages exposing (Msg(..)) 5 | import Views.Link 6 | import Page.NotFound.Views.Styles exposing (CssClasses(..), localClass) 7 | 8 | 9 | view : Html (Msg problem guess) 10 | view = 11 | div [ localClass [ Root ] ] 12 | [ h2 [] [ text "Not found.." ] 13 | , p [] [ text "We all get lost sometimes.." ] 14 | , Views.Link.view "/" [] [ text "Go to home page" ] 15 | ] 16 | -------------------------------------------------------------------------------- /examples/lettero/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | elm-gameroom 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/the-capitalist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | elm-gameroom 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Views/Timer.elm: -------------------------------------------------------------------------------- 1 | module Views.Timer exposing (view) 2 | 3 | import Html exposing (Html, div, text) 4 | import Html.Attributes exposing (style) 5 | import Views.Timer.Styles exposing (CssClasses(..), localClass) 6 | 7 | 8 | view : Float -> Html msg 9 | view ratio = 10 | let 11 | widthPercentage = 12 | ((max (ratio) 0) * 100 |> toString) ++ "%" 13 | in 14 | div 15 | [ localClass [ Root ] 16 | , style 17 | [ ( "transform", "translateX(-" ++ widthPercentage ++ ")" ) 18 | ] 19 | ] 20 | [ text " " ] 21 | -------------------------------------------------------------------------------- /examples/fast-and-moebius/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fast and Moebius 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/spacecraterball/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spacecraterball 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Messages.elm: -------------------------------------------------------------------------------- 1 | module Messages exposing (..) 2 | 3 | import Window 4 | import Data.Route exposing (Route) 5 | import Data.IncomingMessage as InMsg 6 | import Page.Tutorial.Messages 7 | import Page.Game.Messages 8 | import Page.NewRoom.Messages 9 | 10 | 11 | type Msg problem guess 12 | = ChangeRoute (Route problem guess) 13 | | IncomingMessage (InMsg.IncomingMessage problem guess) 14 | | GameMsg (Page.Game.Messages.Msg problem guess) 15 | | NewRoomMsg Page.NewRoom.Messages.Msg 16 | | TutorialMsg (Page.Tutorial.Messages.Msg problem guess) 17 | | Navigate String 18 | | Resize Window.Size 19 | | NoOp 20 | -------------------------------------------------------------------------------- /src/Data/Round.elm: -------------------------------------------------------------------------------- 1 | module Data.Round exposing (..) 2 | 3 | import Json.Decode as JD 4 | import Json.Encode as JE 5 | 6 | 7 | type alias Round problem = 8 | { no : Int 9 | , problem : problem 10 | } 11 | 12 | 13 | encoder : (problem -> JE.Value) -> (Round problem -> JE.Value) 14 | encoder problemEncoder round = 15 | JE.object 16 | [ ( "no", JE.int round.no ) 17 | , ( "problem", problemEncoder round.problem ) 18 | ] 19 | 20 | 21 | decoder : JD.Decoder problem -> JD.Decoder (Round problem) 22 | decoder problemDecoder = 23 | JD.map2 Round 24 | (JD.field "no" JD.int) 25 | (JD.field "problem" problemDecoder) 26 | -------------------------------------------------------------------------------- /src/Views/NoMultiplayer/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.NoMultiplayer.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | 8 | 9 | cssNamespace : String 10 | cssNamespace = 11 | "nomultiplayer" 12 | 13 | 14 | type CssClasses 15 | = Root 16 | 17 | 18 | localClass : List class -> Html.Attribute msg 19 | localClass = 20 | Html.CssHelpers.withNamespace cssNamespace |> .class 21 | 22 | 23 | styles : List Css.Snippet 24 | styles = 25 | [ class Root 26 | [ maxWidth (px 600) 27 | , padding (px 20) 28 | , textAlign center 29 | ] 30 | ] 31 | |> namespace cssNamespace 32 | -------------------------------------------------------------------------------- /examples/counterclockwooze/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | elm-gameroom 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/Tests/Utils.elm: -------------------------------------------------------------------------------- 1 | module Tests.Utils exposing (..) 2 | 3 | import Test exposing (..) 4 | import Expect 5 | import Utils 6 | 7 | 8 | tests : Test 9 | tests = 10 | describe "Utils" 11 | [ describe "template" 12 | [ test "replaces value if ${} is present" <| 13 | \() -> 14 | Utils.template "Hi, my name is ${}." "Peter" 15 | |> Expect.equal "Hi, my name is Peter." 16 | , test "returns original template if ${} is not present" <| 17 | \() -> 18 | Utils.template "Hi, my name is ${." "Peter" 19 | |> Expect.equal "Hi, my name is ${." 20 | ] 21 | ] 22 | -------------------------------------------------------------------------------- /site/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | elm-gameroom 6 | 7 | 8 | 9 | 10 |
11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Views/Scoreboard.elm: -------------------------------------------------------------------------------- 1 | module Views.Scoreboard exposing (view) 2 | 3 | import Html exposing (Html, div, text, span) 4 | import Views.Scoreboard.Styles exposing (CssClasses(..), localClass) 5 | 6 | 7 | view : List ( String, Int ) -> Html msg 8 | view scores = 9 | div 10 | [ localClass [ Root ] 11 | ] 12 | [ scores 13 | |> List.map 14 | (\( player, score ) -> 15 | span [ localClass [ List ] ] 16 | [ span [ localClass [ Player ] ] [ (text player) ] 17 | , span [ localClass [ Score ] ] [ (text (toString score)) ] 18 | ] 19 | ) 20 | |> (\list -> div [] list) 21 | ] 22 | -------------------------------------------------------------------------------- /src/Views/Footer/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Footer.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Mixins as Mixins 8 | 9 | 10 | cssNamespace : String 11 | cssNamespace = 12 | "footer" 13 | 14 | 15 | type CssClasses 16 | = Root 17 | 18 | 19 | localClass : List class -> Html.Attribute msg 20 | localClass = 21 | Html.CssHelpers.withNamespace cssNamespace |> .class 22 | 23 | 24 | styles : List Css.Snippet 25 | styles = 26 | [ class Root <| 27 | [ position fixed 28 | , right (px 0) 29 | , bottom (px 0) 30 | , width (pct 100) 31 | ] 32 | ++ Mixins.standardBoxShadow 33 | ] 34 | |> namespace cssNamespace 35 | -------------------------------------------------------------------------------- /src/Page/NotFound/Views/Styles.elm: -------------------------------------------------------------------------------- 1 | module Page.NotFound.Views.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Elements exposing (a) 7 | import Css.Namespace exposing (namespace) 8 | import Styles.Mixins as Mixins 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "notfound" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | 19 | 20 | localClass : List class -> Html.Attribute msg 21 | localClass = 22 | Html.CssHelpers.withNamespace cssNamespace |> .class 23 | 24 | 25 | styles : List Snippet 26 | styles = 27 | [ class Root 28 | ([ descendants 29 | [ a Mixins.button 30 | ] 31 | ] 32 | ++ Mixins.centered 33 | ) 34 | ] 35 | |> namespace cssNamespace 36 | -------------------------------------------------------------------------------- /src/Views/Timer/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Timer.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Constants exposing (..) 8 | 9 | 10 | cssNamespace : String 11 | cssNamespace = 12 | "timer" 13 | 14 | 15 | type CssClasses 16 | = Root 17 | 18 | 19 | localClass : List class -> Html.Attribute msg 20 | localClass = 21 | Html.CssHelpers.withNamespace cssNamespace |> .class 22 | 23 | 24 | styles : List Snippet 25 | styles = 26 | [ class Root 27 | [ position absolute 28 | , top (px 0) 29 | , width (pct 100) 30 | , height (px 2) 31 | , left (px 0) 32 | , backgroundColor (hex lightBlue) 33 | ] 34 | ] 35 | |> namespace cssNamespace 36 | -------------------------------------------------------------------------------- /bin/run-webrtc-test-server.js: -------------------------------------------------------------------------------- 1 | // This is a test server run for testing the webrtc datastore implementation 2 | const fs = require('fs') 3 | const path = require('path') 4 | const http = require('http') 5 | const port = 3000 6 | 7 | const html = ` 8 | 9 | 10 | 11 | ` 12 | 13 | const server = http.createServer((req, res) => { 14 | if (req.url === '/') { 15 | res.writeHead(200, { 16 | 'Cache-Control': 'no-cache' 17 | }) 18 | res.end(html) 19 | } else if (req.url === '/webrtc.js') { 20 | res.writeHead(200, { 21 | 'Cache-Control': 'no-cache' 22 | }) 23 | fs.createReadStream(path.join(__dirname, '../src/js/db/webrtc.js')).pipe(res) 24 | } 25 | }) 26 | 27 | server.listen(port, () => { 28 | console.log(`Listening on ${port}.`) 29 | }) 30 | -------------------------------------------------------------------------------- /src/Page/Home/Views.elm: -------------------------------------------------------------------------------- 1 | module Page.Home.Views exposing (view) 2 | 3 | import Html exposing (Html, div, text, h1, p) 4 | import Messages exposing (Msg(..)) 5 | import Data.Spec as Spec 6 | import Views.Link as Link 7 | import Page.Home.Views.Styles exposing (CssClasses(..), localClass) 8 | 9 | 10 | view : Spec.DetailedSpec problem guess -> Html (Msg problem guess) 11 | view spec = 12 | div [ localClass [ Root ] ] 13 | [ div [ localClass [ Logo ] ] [ text spec.unicodeIcon ] 14 | , h1 [] [ text spec.name ] 15 | , p [ localClass [ Subheading ] ] [ text spec.subheading ] 16 | , div [ localClass [ Nav ] ] 17 | [ Link.view "/tutorial" [ localClass [ Link ] ] [ text "Try" ] 18 | , Link.view "/new" [ localClass [ Link ] ] [ text "Play" ] 19 | ] 20 | ] 21 | -------------------------------------------------------------------------------- /src/Styles/Constants.elm: -------------------------------------------------------------------------------- 1 | module Styles.Constants exposing (..) 2 | 3 | -- Dimensions 4 | 5 | 6 | standardBorderRadius : Float 7 | standardBorderRadius = 8 | 6 9 | 10 | 11 | 12 | -- Colors (developed under https://coolors.co/e52938-fcfefb-3fafb5-2d739e-173156) 13 | 14 | 15 | black : String 16 | black = 17 | "0F2037" 18 | 19 | 20 | grey : String 21 | grey = 22 | "CFD0CE" 23 | 24 | 25 | white : String 26 | white = 27 | "FCFEFB" 28 | 29 | 30 | lightGrey : String 31 | lightGrey = 32 | "EBECEA" 33 | 34 | 35 | darkGrey : String 36 | darkGrey = 37 | "3d3d3c" 38 | 39 | 40 | blue : String 41 | blue = 42 | "2D739E" 43 | 44 | 45 | lightBlue : String 46 | lightBlue = 47 | "407FA6" 48 | 49 | 50 | faintBlue : String 51 | faintBlue = 52 | "9FBFD2" 53 | 54 | 55 | cyan : String 56 | cyan = 57 | "27ABB2" 58 | 59 | 60 | red : String 61 | red = 62 | "E52938" 63 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.0.0", 3 | "summary": "Framework for custom multiplayer games", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "./src/" 8 | ], 9 | "exposed-modules": [ 10 | "Gameroom", 11 | "Gameroom.Context" 12 | ], 13 | "dependencies": { 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /src/Views/Notification.elm: -------------------------------------------------------------------------------- 1 | module Views.Notification exposing (view) 2 | 3 | import Html exposing (Html, div, p, text) 4 | import Html.Events exposing (onClick) 5 | import Views.Notification.Styles exposing (CssClasses(..), localClass, localClassList) 6 | 7 | 8 | view : Maybe String -> Maybe msg -> Html msg 9 | view body handleClick = 10 | div 11 | [ localClassList 12 | [ ( Root, True ) 13 | , ( RootActive, body /= Nothing ) 14 | , ( RootWithCloseButton, handleClick /= Nothing ) 15 | ] 16 | ] 17 | <| 18 | [ p [] 19 | [ text (body |> Maybe.withDefault "") 20 | ] 21 | ] 22 | ++ (case handleClick of 23 | Just handleClick -> 24 | [ div [ localClass [ Close ], onClick handleClick ] [ text "×" ] ] 25 | 26 | Nothing -> 27 | [] 28 | ) 29 | -------------------------------------------------------------------------------- /src/Views/Scoreboard/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Scoreboard.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Constants exposing (..) 8 | 9 | 10 | cssNamespace : String 11 | cssNamespace = 12 | "scoreboard" 13 | 14 | 15 | type CssClasses 16 | = Root 17 | | List 18 | | Player 19 | | Score 20 | 21 | 22 | localClass : List class -> Html.Attribute msg 23 | localClass = 24 | Html.CssHelpers.withNamespace cssNamespace |> .class 25 | 26 | 27 | styles : List Snippet 28 | styles = 29 | [ class Root 30 | [ width (pct 100) 31 | , padding (px 5) 32 | , backgroundColor (hex lightBlue) 33 | , color (hex white) 34 | , textAlign center 35 | ] 36 | , class List [ margin2 (px 0) (px 20) ] 37 | , class Player [ marginRight (px 8) ] 38 | ] 39 | |> namespace cssNamespace 40 | -------------------------------------------------------------------------------- /site/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "elm-gameroom demo site", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "./src/", 8 | "../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /examples/lettero/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Lettero", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /examples/counterclockwooze/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Counterclockwooze", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /examples/fast-and-moebius/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Fast and Moebius", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /examples/spacecraterball/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Spacecraterball", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /examples/the-capitalist/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Counterclockwooze", 4 | "repository": "https://github.com/peterszerzo/elm-gameroom.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "../../src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "9.1.0 <= v < 10.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0" 23 | }, 24 | "elm-version": "0.18.0 <= v < 0.19.0" 25 | } 26 | -------------------------------------------------------------------------------- /src/Page/Home/Views/Styles.elm: -------------------------------------------------------------------------------- 1 | module Page.Home.Views.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Mixins as Mixins 8 | import Styles.Constants exposing (..) 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "home" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | | Logo 19 | | Link 20 | | Subheading 21 | | Nav 22 | 23 | 24 | localClass : List class -> Html.Attribute msg 25 | localClass = 26 | Html.CssHelpers.withNamespace cssNamespace |> .class 27 | 28 | 29 | styles : List Snippet 30 | styles = 31 | [ class Root Mixins.centered 32 | , class Logo 33 | [ width (px 80) 34 | , height (px 80) 35 | , margin auto 36 | , border3 (px 1) solid (hex white) 37 | , fontSize (px 60) 38 | ] 39 | , class Link Mixins.button 40 | , class Subheading [ fontSize (Css.rem 1.25), margin2 (px 10) auto ] 41 | , class Nav [ marginTop (px 30) ] 42 | ] 43 | |> namespace cssNamespace 44 | -------------------------------------------------------------------------------- /src/Views/Header/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Header.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | 8 | 9 | cssNamespace : String 10 | cssNamespace = 11 | "header" 12 | 13 | 14 | type CssClasses 15 | = Root 16 | | HomeLink 17 | 18 | 19 | localClass : List class -> Html.Attribute msg 20 | localClass = 21 | Html.CssHelpers.withNamespace cssNamespace |> .class 22 | 23 | 24 | styles : List Css.Snippet 25 | styles = 26 | [ class Root 27 | [ position fixed 28 | , display block 29 | , height (px 80) 30 | , width (px 80) 31 | , padding (px 20) 32 | , top (px 0) 33 | , left (px 0) 34 | , zIndex (int 10) 35 | ] 36 | , class HomeLink 37 | [ height (pct 100) 38 | , width (pct 100) 39 | , fontSize (px 40) 40 | , lineHeight (num 1.1) 41 | , textAlign left 42 | , display block 43 | , cursor pointer 44 | ] 45 | ] 46 | |> namespace cssNamespace 47 | -------------------------------------------------------------------------------- /src/Page/Tutorial/Update.elm: -------------------------------------------------------------------------------- 1 | module Page.Tutorial.Update exposing (..) 2 | 3 | import Data.RoundTime as RoundTime 4 | import Page.Tutorial.Messages exposing (Msg(..)) 5 | import Page.Tutorial.Models exposing (Model) 6 | import Data.Spec as Spec 7 | 8 | 9 | update : 10 | Spec.DetailedSpec problem guess 11 | -> Msg problem guess 12 | -> Model problem guess 13 | -- Returns the new model and a boolean value; if it's true, the higher-level update function should generate a new game round. 14 | -> ( Model problem guess, Bool ) 15 | update spec msg model = 16 | case msg of 17 | RequestNewProblem -> 18 | ( model, True ) 19 | 20 | ReceiveProblem problem -> 21 | ( { model 22 | | problem = Just problem 23 | , guess = Nothing 24 | , time = RoundTime.init 25 | } 26 | , False 27 | ) 28 | 29 | Guess guess -> 30 | ( { model | guess = Just guess }, False ) 31 | 32 | Tick time -> 33 | ( { model | time = RoundTime.update time model.time }, False ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Peter Szerzo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Page/NewRoom/Views/Styles.elm: -------------------------------------------------------------------------------- 1 | module Page.NewRoom.Views.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Mixins as Mixins 8 | 9 | 10 | cssNamespace : String 11 | cssNamespace = 12 | "newroom" 13 | 14 | 15 | type CssClasses 16 | = Root 17 | | Logo 18 | | Title 19 | | Button 20 | | ButtonDisabled 21 | | FormButton 22 | 23 | 24 | localClass : List class -> Html.Attribute msg 25 | localClass = 26 | Html.CssHelpers.withNamespace cssNamespace |> .class 27 | 28 | 29 | localClassList : List ( class, Bool ) -> Html.Attribute msg 30 | localClassList = 31 | Html.CssHelpers.withNamespace cssNamespace |> .classList 32 | 33 | 34 | styles : List Snippet 35 | styles = 36 | [ class Root <| Mixins.centered ++ [ textAlign left ] 37 | , class Button Mixins.button 38 | , class ButtonDisabled Mixins.buttonDisabled 39 | , class Title [ textAlign center ] 40 | , class FormButton 41 | [ width (pct 100) 42 | , margin3 (px 25) (px 0) (px 0) 43 | ] 44 | ] 45 | |> namespace cssNamespace 46 | -------------------------------------------------------------------------------- /bin/site.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ROOT=site 4 | DIST=dist 5 | SRC=src 6 | LIBSRC=../src 7 | EXAMPLES=(spacecraterball counterclockwooze lettero the-capitalist fast-and-moebius) 8 | 9 | if [ $1 = "build" ]; then 10 | cd $ROOT 11 | rm -rf $DIST 12 | mkdir $DIST 13 | 14 | cp $LIBSRC/js/talk-to-ports.js $DIST 15 | cp -r $LIBSRC/js/db $DIST 16 | cp $SRC/index.html $DIST 17 | cp $SRC/index.js $DIST 18 | 19 | elm-make src/Main.elm --output $DIST/home.js 20 | uglifyjs $DIST/home.js -o $DIST/home.js --compress --mangle 21 | 22 | for EXAMPLE in "${EXAMPLES[@]}" 23 | do 24 | elm-make ../examples/$EXAMPLE/Main.elm --output $DIST/$EXAMPLE.js 25 | uglifyjs $DIST/$EXAMPLE.js -o $DIST/$EXAMPLE.js --compress --mangle 26 | done 27 | 28 | echo "" 29 | echo "---" 30 | echo "🚧 Important 🚧" 31 | echo "Add firebase config manually to site/dist/index.js!" 32 | echo "---" 33 | elif [ $1 = "deploy" ]; then 34 | cd $ROOT 35 | firebase deploy 36 | elif [ $1 = "run" ]; then 37 | cd $ROOT 38 | elm-live $SRC/Main.elm --output $SRC/home.js --dir=$SRC --open --debug --pushstate 39 | else 40 | echo "Not a valid command. Use either build, deploy or run." 41 | fi 42 | -------------------------------------------------------------------------------- /tests/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Sample Elm Test", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD-3-Clause", 6 | "source-directories": [ 7 | ".", 8 | "../src" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-community/linear-algebra": "3.1.0 <= v < 4.0.0", 13 | "elm-community/webgl": "2.0.3 <= v < 3.0.0", 14 | "elm-lang/animation-frame": "1.0.1 <= v < 2.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 18 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/window": "1.0.1 <= v < 2.0.0", 20 | "evancz/url-parser": "2.0.1 <= v < 3.0.0", 21 | "rtfeldman/elm-css": "8.2.0 <= v < 9.0.0", 22 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0", 23 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0", 24 | "mgold/elm-random-pcg": "4.0.2 <= v < 5.0.0", 25 | "elm-community/elm-test": "3.0.0 <= v < 4.0.0", 26 | "rtfeldman/node-test-runner": "3.0.0 <= v < 4.0.0" 27 | }, 28 | "elm-version": "0.18.0 <= v < 0.19.0" 29 | } 30 | -------------------------------------------------------------------------------- /site/src/index.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | // This is filled out manually in the dist folder, right before uploading. 3 | var firebaseConfig = { 4 | apiKey: '', 5 | authDomain: '', 6 | databaseURL: '', 7 | storageBucket: '', 8 | messagingSenderId: '' 9 | } 10 | var rootNode = document.getElementById('Root') 11 | var games = [ 12 | 'lettero', 13 | 'counterclockwooze', 14 | 'spacecraterball', 15 | 'the-capitalist', 16 | 'fast-and-moebius' 17 | ] 18 | var path = window.location.pathname 19 | var game = games.filter(function (url) { 20 | return new RegExp('^' + url).test(path.slice(1)) 21 | })[0] 22 | if (game) { 23 | var scriptTag = document.createElement('script') 24 | scriptTag.src = '/' + game + '.js?' + window.__ELM_GAMEROOM_HASH__ 25 | scriptTag.onload = function () { 26 | var app = window.Elm.Main.embed(rootNode) 27 | window.talkToPorts(window.db(firebaseConfig, {rootRef: '/' + game}), app.ports) 28 | } 29 | document.body.appendChild(scriptTag) 30 | } else { 31 | var scriptTag = document.createElement('script') 32 | scriptTag.src = '/home.js?' + window.__ELM_GAMEROOM_HASH__ 33 | scriptTag.onload = function () { 34 | var app = window.Elm.Main.embed(rootNode) 35 | } 36 | document.body.appendChild(scriptTag) 37 | } 38 | }()) 39 | -------------------------------------------------------------------------------- /tests/Tests/Data/Player.elm: -------------------------------------------------------------------------------- 1 | module Tests.Data.Player exposing (..) 2 | 3 | import Test exposing (..) 4 | import Expect 5 | import Json.Decode as JD 6 | import Data.Player as Player 7 | 8 | 9 | tests : Test 10 | tests = 11 | describe "Player" 12 | [ describe "decoder" 13 | [ test "decodes player with no current guess" <| 14 | \() -> 15 | "{\"id\": \"player\", \"roomId\": \"room\", \"isReady\": false, \"score\": 0, \"guess\": \"__elm-gameroom__null__\"}" 16 | |> JD.decodeString (Player.decoder JD.int) 17 | |> Result.map (\player -> player.guess == Nothing) 18 | |> Result.withDefault False 19 | |> Expect.equal True 20 | , test "decodes player with current guess" <| 21 | \() -> 22 | "{\"id\": \"player\", \"roomId\": \"room\", \"isReady\": false, \"score\": 0, \"guess\": {\"value\": 2, \"madeAt\": 3}}" 23 | |> JD.decodeString (Player.decoder JD.int) 24 | |> Result.map (\player -> player.guess |> Maybe.map .value |> Maybe.map ((==) 2) |> Maybe.withDefault False) 25 | |> Result.withDefault False 26 | |> Expect.equal True 27 | ] 28 | ] 29 | -------------------------------------------------------------------------------- /src/Data/IncomingMessage.elm: -------------------------------------------------------------------------------- 1 | module Data.IncomingMessage exposing (..) 2 | 3 | import Json.Decode as JD 4 | import Data.Room as Room 5 | 6 | 7 | type IncomingMessage problem guess 8 | = RoomCreated (Room.Room problem guess) 9 | | RoomUpdated (Room.Room problem guess) 10 | 11 | 12 | decoder : JD.Decoder problem -> JD.Decoder guess -> JD.Decoder (IncomingMessage problem guess) 13 | decoder problemDecoder guessDecoder = 14 | JD.map2 (\type_ payload -> ( type_, payload )) 15 | (JD.field "type" JD.string) 16 | (JD.field "payload" JD.value) 17 | |> JD.andThen 18 | (\( type_, payload ) -> 19 | case type_ of 20 | "room:created" -> 21 | JD.decodeValue (Room.decoder problemDecoder guessDecoder) payload 22 | |> Result.map (\room -> JD.succeed (RoomCreated room)) 23 | |> Result.withDefault (JD.fail "Failed to decode created room.") 24 | 25 | "room:updated" -> 26 | JD.decodeValue (Room.decoder problemDecoder guessDecoder) payload 27 | |> Result.map (\room -> JD.succeed (RoomUpdated room)) 28 | |> Result.withDefault (JD.fail "Failed to decode updated room.") 29 | 30 | _ -> 31 | JD.fail "Could not decode incoming message." 32 | ) 33 | -------------------------------------------------------------------------------- /src/Data/RoundTime.elm: -------------------------------------------------------------------------------- 1 | module Data.RoundTime 2 | exposing 3 | ( RoundTime 4 | , init 5 | , update 6 | , justPassed 7 | , timeSinceNewRound 8 | ) 9 | 10 | import Time 11 | 12 | 13 | type RoundTime 14 | = RoundTime 15 | { current : Maybe Time.Time 16 | , atStart : Maybe Time.Time 17 | } 18 | 19 | 20 | init : RoundTime 21 | init = 22 | RoundTime 23 | { current = Nothing 24 | , atStart = Nothing 25 | } 26 | 27 | 28 | timeSinceNewRound : RoundTime -> Time.Time 29 | timeSinceNewRound (RoundTime roundTime) = 30 | Maybe.map2 31 | (\current atStart -> current - atStart) 32 | roundTime.current 33 | roundTime.atStart 34 | |> Maybe.withDefault 0 35 | 36 | 37 | update : Time.Time -> RoundTime -> RoundTime 38 | update time (RoundTime roundTime) = 39 | RoundTime 40 | { roundTime 41 | | current = Just time 42 | , atStart = roundTime.atStart |> Maybe.withDefault time |> Just 43 | } 44 | 45 | 46 | justPassed : Time.Time -> RoundTime -> RoundTime -> Bool 47 | justPassed duration prev current = 48 | let 49 | prevSinceRoundStart = 50 | timeSinceNewRound prev 51 | 52 | currentSinceRoundStart = 53 | timeSinceNewRound current 54 | in 55 | prevSinceRoundStart <= duration && currentSinceRoundStart > duration 56 | -------------------------------------------------------------------------------- /src/Copy.elm: -------------------------------------------------------------------------------- 1 | module Copy exposing (..) 2 | 3 | -- Copy 4 | 5 | 6 | correctGuess : String 7 | correctGuess = 8 | "Correct - let's see if you made it the fastest.." 9 | 10 | 11 | incorrectGuess : String 12 | incorrectGuess = 13 | "Not quite, not quite unfortunately.." 14 | 15 | 16 | evaluatedGuess : String 17 | evaluatedGuess = 18 | "This is scoring you a ${}. Let's see how the others are doing.." 19 | 20 | 21 | win : String 22 | win = 23 | "Nice job, you win!" 24 | 25 | 26 | lose : String 27 | lose = 28 | "This one goes to ${}. Go get them in the next round!" 29 | 30 | 31 | tie : String 32 | tie = 33 | "It's a tie, folks, it's a tie.." 34 | 35 | 36 | tutorialStartup : String 37 | tutorialStartup = 38 | "Hey, let's practice. Click the button to get a game problem you can solve." 39 | 40 | 41 | tutorialEvaluatedGuess : String 42 | tutorialEvaluatedGuess = 43 | "This one will score you a ${}. Can you do better?" 44 | 45 | 46 | newRoomPageTitle : String 47 | newRoomPageTitle = 48 | "Game on!" 49 | 50 | 51 | newRoomFormIntro : String 52 | newRoomFormIntro = 53 | "But first, some forms.. In order to play with your friends, use this form to create your very own room. You’ll then be able to share links unique to each player, control when you feel ready, and be on your way!" 54 | 55 | 56 | casualNamesWarning : String 57 | casualNamesWarning = 58 | "We took the liberty to make your names casual and URL-friendly for your convenience :)." 59 | -------------------------------------------------------------------------------- /src/Page/Game/Views/Styles.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Views.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Elements exposing (ul, li) 7 | import Css.Namespace exposing (namespace) 8 | import Styles.Mixins as Mixins 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "game" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | | GamePlay 19 | | ReadyPrompt 20 | | Link 21 | | DisabledLink 22 | | LoaderContainer 23 | 24 | 25 | localClass : List class -> Html.Attribute msg 26 | localClass = 27 | Html.CssHelpers.withNamespace cssNamespace |> .class 28 | 29 | 30 | localClassList : List ( class, Bool ) -> Html.Attribute msg 31 | localClassList = 32 | Html.CssHelpers.withNamespace cssNamespace |> .classList 33 | 34 | 35 | styles : List Css.Snippet 36 | styles = 37 | [ class Root [] 38 | , class GamePlay 39 | [ position absolute 40 | , top (px 0) 41 | , bottom (px 0) 42 | , left (px 0) 43 | , right (px 0) 44 | ] 45 | , class ReadyPrompt <| 46 | [ descendants 47 | [ ul 48 | [ listStyle none 49 | , margin (px 0) 50 | , padding (px 0) 51 | ] 52 | , li 53 | [ display inlineBlock 54 | ] 55 | ] 56 | ] 57 | ++ Mixins.centered 58 | , class Link Mixins.button 59 | , class DisabledLink Mixins.buttonDisabled 60 | , class LoaderContainer Mixins.centered 61 | ] 62 | |> namespace cssNamespace 63 | -------------------------------------------------------------------------------- /src/Gameroom/Context.elm: -------------------------------------------------------------------------------- 1 | module Gameroom.Context exposing (Context) 2 | 3 | {-| When developing elm-gameroom games, the [Spec](/Gameroom-Spec) object requires a view function that controls how the problem should be represented. This function takes a context as a first parameter, which you can use to display additional clues in your game, such as feedback when a guess is made or when the correct guess may be revealed. It also gives you access the current round's timer so you can get all animated and even WebGL-y, if that strikes your fancy. 4 | 5 | # The Context type 6 | @docs Context 7 | -} 8 | 9 | import Window 10 | import Time 11 | 12 | 13 | {-| The context object, containing the following fields: 14 | 15 | * `windowSize`: the size of the window, as per `elm-lang/window`. 16 | * `roundTime`: time since the current round started. 17 | * `ownGuess`: the guess made by the current client, if any. 18 | * `opponentGuesses`: a `( String, guess )` tuple listing any guesses made by opponents. 19 | * `isRoundOver`: states whether the current game round has been decided. At this point, you can reveal the correct answer while the round is in cooldown. 20 | * `scores`: lists the scores of each player as a `List ( String, Int )`. Use if the [noPeripheralUi](/Gameroom#noPeripheralUi) setting is enabled, and you want to render a custom-styles scoreboard on your own. 21 | -} 22 | type alias Context guess = 23 | { windowSize : Window.Size 24 | , roundTime : Time.Time 25 | , ownGuess : Maybe guess 26 | , opponentGuesses : List ( String, guess ) 27 | , isRoundOver : Bool 28 | , scores : List ( String, Int ) 29 | } 30 | -------------------------------------------------------------------------------- /src/Data/OutgoingMessage.elm: -------------------------------------------------------------------------------- 1 | module Data.OutgoingMessage exposing (..) 2 | 3 | import Json.Encode as JE 4 | import Data.Room as Room 5 | import Data.Player as Player 6 | 7 | 8 | type OutgoingMessage problem guess 9 | = CreateRoom (Room.Room problem guess) 10 | | UpdateRoom (Room.Room problem guess) 11 | | UpdatePlayer (Player.Player guess) 12 | | SubscribeToRoom String 13 | | UnsubscribeFromRoom String 14 | 15 | 16 | encoder : 17 | (problem -> JE.Value) 18 | -> (guess -> JE.Value) 19 | -> OutgoingMessage problem guess 20 | -> JE.Value 21 | encoder problemEncoder guessEncoder cmd = 22 | case cmd of 23 | CreateRoom room -> 24 | JE.object 25 | [ ( "type", JE.string "create:room" ) 26 | , ( "payload", Room.encoder problemEncoder guessEncoder room ) 27 | ] 28 | 29 | UpdateRoom room -> 30 | JE.object 31 | [ ( "type", JE.string "update:room" ) 32 | , ( "payload", Room.encoder problemEncoder guessEncoder room ) 33 | ] 34 | 35 | UpdatePlayer player -> 36 | JE.object 37 | [ ( "type", JE.string "update:player" ) 38 | , ( "payload", Player.encoder guessEncoder player ) 39 | ] 40 | 41 | SubscribeToRoom roomId -> 42 | JE.object 43 | [ ( "type", JE.string "subscribeto:room" ) 44 | , ( "payload", JE.string roomId ) 45 | ] 46 | 47 | UnsubscribeFromRoom roomId -> 48 | JE.object 49 | [ ( "type", JE.string "unsubscribefrom:room" ) 50 | , ( "payload", JE.string roomId ) 51 | ] 52 | -------------------------------------------------------------------------------- /tests/Tests/Data/OutgoingMessage.elm: -------------------------------------------------------------------------------- 1 | module Tests.Data.OutgoingMessage exposing (..) 2 | 3 | import Test exposing (..) 4 | import Expect 5 | import Data.Room as Room 6 | import Json.Decode as JD 7 | import Json.Encode as JE 8 | import Data.OutgoingMessage exposing (OutgoingMessage(..), encoder) 9 | 10 | 11 | type alias Problem = 12 | String 13 | 14 | 15 | problemEncoder : Problem -> JE.Value 16 | problemEncoder = 17 | JE.string 18 | 19 | 20 | type alias Guess = 21 | String 22 | 23 | 24 | guessEncoder : Guess -> JE.Value 25 | guessEncoder = 26 | JE.string 27 | 28 | 29 | testRoom : Room.Room Problem Guess 30 | testRoom = 31 | Room.create "room" [ "player1", "player2" ] 32 | 33 | 34 | tests : Test 35 | tests = 36 | describe "OutgoingMessage" 37 | [ describe "encoder" 38 | [ test "adds correct type value to CreateRoom message" <| 39 | \() -> 40 | CreateRoom testRoom 41 | |> encoder problemEncoder guessEncoder 42 | |> JD.decodeValue (JD.at [ "type" ] JD.string) 43 | |> Result.map ((==) "create:room") 44 | |> Result.withDefault False 45 | |> Expect.equal True 46 | , test "adds correct type value to UpdateRoom message" <| 47 | \() -> 48 | UpdateRoom testRoom 49 | |> encoder problemEncoder guessEncoder 50 | |> JD.decodeValue (JD.at [ "type" ] JD.string) 51 | |> Result.map ((==) "update:room") 52 | |> Result.withDefault False 53 | |> Expect.equal True 54 | ] 55 | ] 56 | -------------------------------------------------------------------------------- /src/js/db/firebase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Firebase data store 3 | * @returns {Object} db - Datastore instance, following elm-gameroom API. 4 | */ 5 | ;(function (root, factory) { 6 | if (typeof module === 'object' && module.exports) { 7 | module.exports = factory(root) 8 | } else { 9 | root.db = factory(root) 10 | } 11 | }(this, function (window) { 12 | var db = function (firebaseConfig, options) { 13 | var firebase = window.firebase 14 | 15 | var rootRef = (options && options.rootRef) || '' 16 | 17 | var firebaseApp = firebase.initializeApp(firebaseConfig) 18 | 19 | var database = firebaseApp.database() 20 | 21 | return { 22 | getRoom: function (roomId) { 23 | return database.ref(rootRef + '/rooms/' + roomId) 24 | .once('value') 25 | .then(function (snapshot) { 26 | return snapshot.val() 27 | }) 28 | }, 29 | 30 | createRoom: function (room) { 31 | return database.ref(rootRef + '/rooms/' + room.id).set(room) 32 | }, 33 | 34 | updateRoom: function (room) { 35 | return database.ref(rootRef + '/rooms/' + room.id).set(room) 36 | }, 37 | 38 | updatePlayer: function (player) { 39 | return database.ref(rootRef + '/rooms/' + player.roomId + '/players/' + player.id).set(player) 40 | }, 41 | 42 | subscribeToRoom: function (roomId, next) { 43 | return database.ref(rootRef + '/rooms/' + roomId).on('value', function (snapshot) { 44 | next(snapshot.val()) 45 | }) 46 | }, 47 | 48 | unsubscribeFromRoom: function (roomId) { 49 | return database.ref(rootRef + '/rooms/' + roomId).off() 50 | } 51 | } 52 | } 53 | 54 | return db 55 | })) 56 | -------------------------------------------------------------------------------- /src/Page/Tutorial/Views/Styles.elm: -------------------------------------------------------------------------------- 1 | module Page.Tutorial.Views.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Constants exposing (..) 8 | import Styles.Mixins as Mixins 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "tutorial" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | | Button 19 | 20 | 21 | localClass : List class -> Html.Attribute msg 22 | localClass = 23 | Html.CssHelpers.withNamespace cssNamespace |> .class 24 | 25 | 26 | styles : List Snippet 27 | styles = 28 | [ class Root 29 | [ position absolute 30 | , left (px 0) 31 | , top (px 0) 32 | , bottom (px 0) 33 | , right (px 0) 34 | ] 35 | , class Button <| 36 | [ width (px 40) 37 | , height (px 40) 38 | , backgroundColor (hex blue) 39 | , color (hex white) 40 | , position fixed 41 | , bottom (px 20) 42 | , lineHeight (num 1) 43 | , left (px 20) 44 | , textAlign center 45 | , fontSize (px 20) 46 | , paddingTop (px 10) 47 | , borderRadius (pct 50) 48 | , after 49 | [ property "content" "' '" 50 | , position absolute 51 | , top (px 0) 52 | , left (px 0) 53 | , bottom (px 0) 54 | , right (px 0) 55 | , property "transition" "background-color 0.3s" 56 | ] 57 | , hover 58 | [ after 59 | [ property "background-color" "rgba(255, 255, 255, 0.1)" 60 | ] 61 | ] 62 | ] 63 | ++ Mixins.standardBoxShadow 64 | ] 65 | |> namespace cssNamespace 66 | -------------------------------------------------------------------------------- /src/Subscriptions.elm: -------------------------------------------------------------------------------- 1 | module Subscriptions exposing (..) 2 | 3 | import Window 4 | import Time 5 | import Json.Decode as JD 6 | import Messages 7 | import Data.Route as Route 8 | import Models exposing (Model) 9 | import Page.Tutorial.Messages 10 | import Page.Game.Messages 11 | import Data.Spec as Spec 12 | import Data.IncomingMessage as InMsg 13 | import AnimationFrame 14 | 15 | 16 | subscriptions : 17 | Spec.DetailedSpec problem guess 18 | -> Model problem guess 19 | -> Sub (Messages.Msg problem guess) 20 | subscriptions spec model = 21 | Sub.batch 22 | [ (spec.ports 23 | |> Maybe.map .incoming 24 | |> Maybe.withDefault (always Sub.none) 25 | ) 26 | (\val -> 27 | val 28 | |> JD.decodeValue (InMsg.decoder spec.problemDecoder spec.guessDecoder) 29 | |> Result.map Messages.IncomingMessage 30 | |> Result.withDefault Messages.NoOp 31 | ) 32 | , case model.route of 33 | Route.Game _ -> 34 | Sub.batch 35 | [ -- This extra timer is necessary for when the game is tested in two different browser windows (animationframe doesn't fire when the tab is not active). 36 | Time.every (100 * Time.millisecond) (Messages.GameMsg << Page.Game.Messages.Tick) 37 | , AnimationFrame.times (Messages.GameMsg << Page.Game.Messages.Tick) 38 | , Window.resizes Messages.Resize 39 | ] 40 | 41 | Route.Tutorial _ -> 42 | Sub.batch 43 | [ AnimationFrame.times (Messages.TutorialMsg << Page.Tutorial.Messages.Tick) 44 | , Window.resizes Messages.Resize 45 | ] 46 | 47 | _ -> 48 | Sub.none 49 | ] 50 | -------------------------------------------------------------------------------- /src/Router.elm: -------------------------------------------------------------------------------- 1 | module Router exposing (..) 2 | 3 | import Navigation 4 | import UrlParser exposing (..) 5 | import Data.Route exposing (Route(..)) 6 | import Page.NewRoom.Models 7 | import Page.Game.Models 8 | import Page.Tutorial.Models 9 | 10 | 11 | startsWithBase : String -> String -> Bool 12 | startsWithBase basePath path_ = 13 | String.left (String.length basePath) path_ == basePath 14 | 15 | 16 | sWithBasePath : String -> String -> Parser a a 17 | sWithBasePath basePath slug = 18 | -- Redefines UrlParser's 's' function to take into account a base path. 19 | let 20 | baseSlug = 21 | String.dropLeft 1 basePath 22 | in 23 | if baseSlug == "" then 24 | s slug 25 | else 26 | (if slug == "" then 27 | s baseSlug 28 | else 29 | s baseSlug s slug 30 | ) 31 | 32 | 33 | matchers : String -> UrlParser.Parser (Route problem guess -> a) a 34 | matchers basePath = 35 | let 36 | s_ = 37 | sWithBasePath basePath 38 | in 39 | UrlParser.oneOf 40 | [ s_ "" |> map Home 41 | , s_ "tutorial" |> map (Tutorial Page.Tutorial.Models.init) 42 | , s_ "rooms" 43 | string 44 | string 45 | |> map 46 | (\roomId playerId -> 47 | Page.Game.Models.init roomId playerId 48 | |> Game 49 | ) 50 | , s_ "new" |> map (NewRoom Page.NewRoom.Models.init) 51 | ] 52 | 53 | 54 | parse : String -> Navigation.Location -> Route problem guess 55 | parse basePath location = 56 | if startsWithBase basePath location.pathname then 57 | location 58 | |> UrlParser.parsePath (matchers basePath) 59 | |> Maybe.withDefault NotFound 60 | else 61 | NotOnBaseRoute 62 | -------------------------------------------------------------------------------- /src/js/talk-to-ports.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function handles communication between the Elm ports and the datastore. 3 | * @param {object} db - datastore API 4 | * @param {object} ports - Elm ports 5 | * @return {object} ports 6 | */ 7 | ;(function (root, factory) { 8 | if (typeof module === 'object' && module.exports) { 9 | module.exports = factory(root) 10 | } else { 11 | root.talkToPorts = factory(root) 12 | } 13 | }(this, function (window) { 14 | return function talkToPorts (db, ports) { 15 | ports.outgoing.subscribe(function (data) { 16 | var type = data.type 17 | var payload = data.payload 18 | switch (type) { 19 | // Subscribe to room, sending room:updated messages 20 | case 'subscribeto:room': 21 | return db.subscribeToRoom(payload, function (room) { 22 | ports.incoming.send({ 23 | type: 'room:updated', 24 | payload: room 25 | }) 26 | }) 27 | // Unsubscribe from room, making sure room:updated messages are no longer sent 28 | case 'unsubscribefrom:room': 29 | return db.unsubscribeFromRoom(payload) 30 | // Create new game room in storage, sending back a room:created message. 31 | case 'create:room': 32 | return db.createRoom(payload).then(function () { 33 | ports.incoming.send({ 34 | type: 'room:created', 35 | payload: payload 36 | }) 37 | }) 38 | // Update room. If subscribed, this should signal back to the roomUpdated port. 39 | // Hence, no feedback is necessary in this method. 40 | case 'update:room': 41 | return db.updateRoom(payload) 42 | case 'update:player': 43 | return db.updatePlayer(payload) 44 | default: 45 | return 46 | } 47 | }) 48 | 49 | return ports 50 | } 51 | })) 52 | -------------------------------------------------------------------------------- /src/Views/Attribution/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Attribution.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Elements exposing (svg, p, a, span) 7 | import Css.Namespace exposing (namespace) 8 | import Styles.Constants exposing (..) 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "attribution" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | 19 | 20 | localClass : List class -> Html.Attribute msg 21 | localClass = 22 | Html.CssHelpers.withNamespace cssNamespace |> .class 23 | 24 | 25 | styles : List Css.Snippet 26 | styles = 27 | [ Css.class Root 28 | [ position fixed 29 | , bottom (px 10) 30 | , opacity (num 0.4) 31 | , width (px 240) 32 | , property "left" "calc(50% - 120px)" 33 | , color (hex black) 34 | , textDecoration none 35 | , fontSize (Css.rem 0.75) 36 | , property "transition" "all 0.3s" 37 | , hover 38 | [ opacity (num 1.0) 39 | ] 40 | , descendants 41 | [ svg 42 | [ width (px 24) 43 | , height (px 24) 44 | , display inlineBlock 45 | , verticalAlign middle 46 | , position relative 47 | , top (px -5) 48 | ] 49 | , everything 50 | [ display inlineBlock 51 | , verticalAlign middle 52 | ] 53 | , span 54 | [ firstOfType 55 | [ width (px 100) 56 | , textAlign right 57 | , marginRight (px 6) 58 | ] 59 | , lastOfType 60 | [ width (px 100) 61 | , textAlign left 62 | , marginLeft (px 6) 63 | ] 64 | ] 65 | ] 66 | ] 67 | ] 68 | |> namespace cssNamespace 69 | -------------------------------------------------------------------------------- /src/Views/Notification/Styles.elm: -------------------------------------------------------------------------------- 1 | module Views.Notification.Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Namespace exposing (namespace) 7 | import Styles.Constants exposing (..) 8 | import Styles.Mixins as Mixins 9 | 10 | 11 | cssNamespace : String 12 | cssNamespace = 13 | "notification" 14 | 15 | 16 | type CssClasses 17 | = Root 18 | | RootActive 19 | | RootWithCloseButton 20 | | Close 21 | 22 | 23 | localClass : List class -> Html.Attribute msg 24 | localClass = 25 | Html.CssHelpers.withNamespace cssNamespace |> .class 26 | 27 | 28 | localClassList : List ( class, Bool ) -> Html.Attribute msg 29 | localClassList = 30 | Html.CssHelpers.withNamespace cssNamespace |> .classList 31 | 32 | 33 | styles : List Snippet 34 | styles = 35 | [ class Root <| 36 | [ position fixed 37 | , top (px 20) 38 | , right (px 20) 39 | , textAlign left 40 | , opacity (num 0) 41 | , maxWidth (px 240) 42 | , property "pointer-events" "none" 43 | , backgroundColor (hex blue) 44 | , color (hex white) 45 | , borderRadius (px standardBorderRadius) 46 | , padding2 (px 10) (px 20) 47 | , descendants 48 | [ everything [ margin (px 0) ] 49 | ] 50 | ] 51 | ++ Mixins.standardBoxShadow 52 | , class RootActive 53 | [ opacity (num 1) 54 | , property "pointer-events" "all" 55 | , property "transition" "opacity 0.3s" 56 | ] 57 | , class RootWithCloseButton 58 | [ padding4 (px 10) (px 30) (px 10) (px 20) 59 | ] 60 | , class Close 61 | [ position absolute 62 | , top (px 0) 63 | , right (px 0) 64 | , padding (px 10) 65 | , color (hex white) 66 | , lineHeight (num 0.8) 67 | , fontSize (Css.rem 2) 68 | , cursor pointer 69 | ] 70 | ] 71 | |> namespace cssNamespace 72 | -------------------------------------------------------------------------------- /src/js/README.md: -------------------------------------------------------------------------------- 1 | # elm-gameroom JavaScript utilities 2 | 3 | An Elm app running on `elm-gameroom` needs a thin piece of JavaScript that talks to a generic backend. This folder provides a generic datastore API implemented in generic backends, as well as the of glue code that talks to the Elm app's ports. 4 | 5 | These pieces of JavaScript are considered boilerplate, and `elm-gameroom` aims to keep them to a minimum. 6 | 7 | ## db 8 | 9 | The `db/*.js` files each export a function taking in some configuration, and returning an object with methods that interact with the back-end. This API is the following: 10 | 11 | ```js 12 | { 13 | getRoom: function (id) { 14 | // Retrieves the room object by a given id. 15 | }, 16 | 17 | setRoom: function (room) { 18 | // Stores the room object coming from Elm in the datastore. 19 | }, 20 | 21 | subscribeToRoom: function (roomId, onValue) { 22 | // Subscribes to a room, calling callback when it changes (including at the start) 23 | }, 24 | 25 | unsubscribeFromRoom: function (roomId) { 26 | // Clears all subscribers from the given room for the given client 27 | }, 28 | 29 | setPlayer: function (player) { 30 | // Sets a player, nested inside the room under the players field. 31 | // Note that the player object contains an id and roomId fields. 32 | // E.g. in Firebase, they can be found under the key: 33 | // `/rooms/${player.roomId}/players/${player.id}` 34 | } 35 | } 36 | ``` 37 | 38 | ## talk-to-ports.js 39 | 40 | If your database implementation follows the `db` API, then you can use this piece of code to talk to your Elm ports, as follows: 41 | 42 | ```js 43 | import talkToPorts from '~/elm-stuff/packages/peterszerzo/elm-gameroom/1.0.0/src/js/talk-to-ports' 44 | 45 | // Import local storage db implementation 46 | import db from '~/elm-stuff/packages/peterszerzo/elm-gameroom/1.0.0/src/js/db/local-storage' 47 | 48 | // Start Elm app 49 | const app = Elm.Main.embed(document.getElementById('root')) 50 | 51 | // Talk to Elm's ports 52 | talkToPorts(db, app.ports) 53 | ``` 54 | -------------------------------------------------------------------------------- /src/js/db/local-storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple local db, polling localStorage. 3 | * Only works if both clients are in the same browser window. 4 | * => only useful for testing games. 5 | */ 6 | ;(function (root, factory) { 7 | if (typeof module === 'object' && module.exports) { 8 | module.exports = factory(root) 9 | } else { 10 | root.db = factory(root) 11 | } 12 | }(this, function (window) { 13 | var db = function () { 14 | var localStorage = window.localStorage 15 | 16 | var _subscribersByRoomId = {} 17 | 18 | return { 19 | getRoom: function (roomId) { 20 | return Promise.resolve(JSON.parse(localStorage.getItem('/rooms/' + roomId))) 21 | }, 22 | 23 | createRoom: function (room) { 24 | localStorage.setItem('/rooms/' + room.id, JSON.stringify(room)) 25 | return Promise.resolve(room) 26 | }, 27 | 28 | updateRoom: function (room) { 29 | localStorage.setItem('/rooms/' + room.id, JSON.stringify(room)) 30 | return Promise.resolve(room) 31 | }, 32 | 33 | updatePlayer: function (player) { 34 | var room = JSON.parse(localStorage.getItem('/rooms/' + player.roomId)) 35 | room.players[player.id] = player 36 | localStorage.setItem('/rooms/' + player.roomId, JSON.stringify(room)) 37 | return Promise.resolve(player) 38 | }, 39 | 40 | subscribeToRoom: function (roomId, onValue) { 41 | var previousValue 42 | _subscribersByRoomId[roomId] = { 43 | onValue: onValue, 44 | interval: window.setInterval(function () { 45 | var value = localStorage.getItem('/rooms/' + roomId) 46 | if (previousValue !== value) { 47 | onValue(JSON.parse(value)) 48 | } 49 | previousValue = value 50 | }, 100) 51 | } 52 | return onValue 53 | }, 54 | 55 | unsubscribeFromRoom: function (roomId) { 56 | window.clearInterval(_subscribersByRoomId[roomId].interval) 57 | _subscribersByRoomId[roomId] = null 58 | } 59 | } 60 | } 61 | 62 | return db 63 | })) 64 | -------------------------------------------------------------------------------- /src/Page/Game/Helpers.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Helpers exposing (..) 2 | 3 | import Data.Spec as Spec 4 | import Page.Game.Models exposing (Model, getOwnGuess) 5 | import Data.Room as Room 6 | import Data.RoundTime as RoundTime 7 | import Copy 8 | import Utils 9 | 10 | 11 | getNotificationContent : Spec.DetailedSpec problem guess -> Model problem guess -> Maybe String 12 | getNotificationContent spec model = 13 | let 14 | roundTime = 15 | RoundTime.timeSinceNewRound model.time 16 | in 17 | case model.room of 18 | Just room -> 19 | if (roundTime < spec.roundDuration) then 20 | Maybe.map2 21 | (\guess round -> 22 | let 23 | eval = 24 | spec.evaluate round.problem guess.value 25 | in 26 | case spec.clearWinnerEvaluation of 27 | Just clearWinnerEval -> 28 | if eval == clearWinnerEval then 29 | Copy.correctGuess 30 | else 31 | Copy.incorrectGuess 32 | 33 | Nothing -> 34 | Utils.template Copy.evaluatedGuess (toString eval) 35 | ) 36 | (getOwnGuess model) 37 | room.round 38 | else 39 | Room.getRoundWinner spec.evaluate spec.clearWinnerEvaluation room 40 | |> Maybe.map 41 | (\winnerId -> 42 | if winnerId == model.playerId then 43 | Copy.win 44 | else 45 | Utils.template Copy.lose winnerId 46 | ) 47 | |> Maybe.withDefault Copy.tie 48 | |> Just 49 | 50 | Nothing -> 51 | Nothing 52 | -------------------------------------------------------------------------------- /src/Page/Game/Models.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Models exposing (..) 2 | 3 | import Dict 4 | import Data.Guess exposing (Guess) 5 | import Data.Room as Room 6 | import Data.RoundTime as RoundTime 7 | import Data.Player as Player 8 | 9 | 10 | type alias Model problem guess = 11 | { roomId : Room.RoomId 12 | , playerId : Player.PlayerId 13 | , room : Maybe (Room.Room problem guess) 14 | , time : RoundTime.RoundTime 15 | } 16 | 17 | 18 | 19 | -- Helpers 20 | 21 | 22 | init : Room.RoomId -> Player.PlayerId -> Model problem guess 23 | init roomId playerId = 24 | { roomId = roomId 25 | , playerId = playerId 26 | , room = Nothing 27 | , time = RoundTime.init 28 | } 29 | 30 | 31 | isHost : Model problem guess -> Bool 32 | isHost game = 33 | game.room 34 | |> Maybe.map (\room -> room.host == game.playerId) 35 | |> Maybe.withDefault False 36 | 37 | 38 | setOwnGuess : guess -> Model problem guess -> Model problem guess 39 | setOwnGuess guess model = 40 | case model.room of 41 | Just room -> 42 | let 43 | newGuess = 44 | Just { value = guess, madeAt = RoundTime.timeSinceNewRound model.time } 45 | 46 | players = 47 | room.players 48 | |> Dict.map 49 | (\playerId player -> 50 | if playerId == model.playerId then 51 | { player | guess = newGuess } 52 | else 53 | player 54 | ) 55 | 56 | newRoom = 57 | { room | players = players } 58 | in 59 | { model | room = Just newRoom } 60 | 61 | Nothing -> 62 | model 63 | 64 | 65 | getOwnPlayer : Model problem guess -> Maybe (Player.Player guess) 66 | getOwnPlayer model = 67 | model.room 68 | |> Maybe.map .players 69 | |> Maybe.andThen (Dict.get model.playerId) 70 | 71 | 72 | getOwnGuess : Model problem guess -> Maybe (Guess guess) 73 | getOwnGuess model = 74 | getOwnPlayer model 75 | |> Maybe.andThen .guess 76 | -------------------------------------------------------------------------------- /src/Page/Tutorial/Views.elm: -------------------------------------------------------------------------------- 1 | module Page.Tutorial.Views exposing (view) 2 | 3 | import Window 4 | import Html exposing (Html, map, div, text, button, h1, h2, label, input, fieldset, span, ul, li, a, p) 5 | import Html.Events exposing (onClick) 6 | import Data.RoundTime as RoundTime 7 | import Data.Spec as Spec 8 | import Page.Tutorial.Models exposing (Model) 9 | import Page.Tutorial.Messages exposing (Msg(..)) 10 | import Page.Tutorial.Views.Styles exposing (CssClasses(..), localClass) 11 | import Views.Notification 12 | import Utils 13 | import Copy 14 | 15 | 16 | view : 17 | Spec.DetailedSpec problem guess 18 | -> Window.Size 19 | -> Model problem guess 20 | -> Html (Msg problem guess) 21 | view spec windowSize model = 22 | let 23 | roundTime = 24 | RoundTime.timeSinceNewRound model.time 25 | in 26 | div 27 | [ localClass [ Root ] 28 | ] 29 | [ Views.Notification.view 30 | (model.problem 31 | |> Maybe.map 32 | (\problem -> 33 | case model.guess of 34 | Nothing -> 35 | spec.instructions 36 | 37 | Just guess -> 38 | Utils.template Copy.tutorialEvaluatedGuess (spec.evaluate problem guess |> toString) 39 | ) 40 | |> Maybe.withDefault Copy.tutorialStartup 41 | |> Just 42 | ) 43 | Nothing 44 | , div [ localClass [ Button ], onClick RequestNewProblem ] [ text "▶" ] 45 | , model.problem 46 | |> Maybe.map 47 | (spec.view 48 | { windowSize = windowSize 49 | , roundTime = roundTime 50 | , ownGuess = model.guess 51 | , opponentGuesses = [] 52 | , isRoundOver = False 53 | , scores = [] 54 | } 55 | ) 56 | |> Maybe.map (map Guess) 57 | |> Maybe.withDefault (div [] []) 58 | ] 59 | -------------------------------------------------------------------------------- /src/Styles/Shared.elm: -------------------------------------------------------------------------------- 1 | module Styles.Shared exposing (..) 2 | 3 | import Css exposing (..) 4 | import Css.Elements exposing (html, body, input, label, button, a, h1, h2, h3, p) 5 | import Styles.Constants exposing (..) 6 | import Styles.Mixins as Mixins 7 | 8 | 9 | type CssIds 10 | = Root 11 | 12 | 13 | styles : List Snippet 14 | styles = 15 | [ everything 16 | [ boxSizing borderBox 17 | , property "font-family" "Source Sans Pro, Verdana, Geneva, sans-serif" 18 | , property "-webkit-font-smoothing" "antialiased" 19 | , property "-moz-osx-font-smoothing" "grayscale" 20 | ] 21 | , html 22 | [ fontSize (pct 80) 23 | ] 24 | , mediaQuery ("screen and (min-width: 600px)") 25 | [ html 26 | [ fontSize (pct 100) 27 | ] 28 | ] 29 | , each [ html, body ] 30 | [ padding (px 0) 31 | , margin (px 0) 32 | , width (pct 100) 33 | , height (pct 100) 34 | ] 35 | , body 36 | [ position relative 37 | , color (hex black) 38 | ] 39 | , id Root 40 | [ width (pct 100) 41 | , height (pct 100) 42 | ] 43 | , h1 Mixins.heroType 44 | , h2 Mixins.subheroType 45 | , h3 Mixins.headingType 46 | , p Mixins.bodyType 47 | , input 48 | [ display block 49 | , width (pct 100) 50 | , padding2 (px 8) (px 8) 51 | , borderRadius (px 4) 52 | , outline none 53 | , boxShadow none 54 | , fontSize (Css.rem 1) 55 | , border3 (px 1) solid (hex grey) 56 | , marginTop (px 6) 57 | , property "transition" "border 0.3s" 58 | , focus 59 | [ borderColor (hex blue) 60 | ] 61 | ] 62 | , label 63 | [ display block 64 | , textAlign left 65 | , marginTop (px 30) 66 | , width (pct 100) 67 | , position relative 68 | , color (hex darkGrey) 69 | , children 70 | [ button 71 | [ position absolute 72 | , top (px 0) 73 | , right (px 0) 74 | , border (px 0) 75 | , fontSize (Css.rem 1) 76 | , backgroundColor transparent 77 | ] 78 | ] 79 | ] 80 | ] 81 | -------------------------------------------------------------------------------- /site/src/Styles.elm: -------------------------------------------------------------------------------- 1 | module Styles exposing (..) 2 | 3 | import Html 4 | import Html.CssHelpers 5 | import Css exposing (..) 6 | import Css.Elements exposing (svg, h1, main_, footer, header) 7 | import Css.Namespace exposing (namespace) 8 | import Styles.Shared 9 | import Styles.Mixins 10 | 11 | 12 | cssNamespace : String 13 | cssNamespace = 14 | "elmgameroomhome" 15 | 16 | 17 | type CssClasses 18 | = Root 19 | | Link 20 | | SimpleLink 21 | 22 | 23 | css : Css.Stylesheet 24 | css = 25 | stylesheet <| 26 | ([ class Root 27 | [ padding2 (px 60) (px 20) 28 | , textAlign center 29 | , display block 30 | , descendants 31 | [ svg 32 | [ width (px 60) 33 | , height (px 60) 34 | , margin auto 35 | ] 36 | , h1 37 | [ property "font-weight" "300" 38 | ] 39 | ] 40 | ] 41 | , class Link Styles.Mixins.button 42 | , class SimpleLink 43 | [ textDecoration none 44 | , color (hex "2D739E") 45 | , borderBottom3 (px 1) solid currentColor 46 | , property "transition" "all 0.3s" 47 | , hover [ color (hex "3890c6") ] 48 | ] 49 | , header 50 | [ paddingBottom (px 30) 51 | ] 52 | , main_ 53 | [ padding2 (px 60) (px 20) 54 | , maxWidth (px 640) 55 | , margin auto 56 | , borderTop3 (px 1) solid (hex "cccccc") 57 | , borderBottom3 (px 1) solid (hex "cccccc") 58 | ] 59 | , footer 60 | [ maxWidth (px 640) 61 | , padding2 (px 30) (px 40) 62 | , margin auto 63 | , descendants 64 | [ everything 65 | [ maxWidth (px 400) 66 | ] 67 | ] 68 | ] 69 | ] 70 | |> namespace cssNamespace 71 | ) 72 | ++ Styles.Shared.styles 73 | 74 | 75 | cssText : String 76 | cssText = 77 | compile [ css ] |> .css 78 | 79 | 80 | localClass : List class -> Html.Attribute msg 81 | localClass = 82 | Html.CssHelpers.withNamespace cssNamespace |> .class 83 | -------------------------------------------------------------------------------- /src/Views.elm: -------------------------------------------------------------------------------- 1 | module Views exposing (view) 2 | 3 | import Html exposing (Html, div, node, p, a, text) 4 | import Html.Attributes exposing (href) 5 | import Data.Route as Route 6 | import Data.Spec as Spec 7 | import Models exposing (Model) 8 | import Messages exposing (Msg(..)) 9 | import Page.Game.Views 10 | import Page.Home.Views 11 | import Page.NewRoom.Views 12 | import Page.NotFound.Views 13 | import Page.Tutorial.Views 14 | import Views.Layout 15 | import Views.NoMultiplayer 16 | 17 | 18 | view : Spec.DetailedSpec problem guess -> Model problem guess -> Html (Msg problem guess) 19 | view spec model = 20 | let 21 | isHome = 22 | model.route == Route.Home 23 | 24 | noMultiplayer = 25 | spec.ports == Nothing 26 | 27 | content = 28 | case model.route of 29 | Route.Home -> 30 | Page.Home.Views.view spec 31 | 32 | Route.Game game -> 33 | if noMultiplayer then 34 | Views.NoMultiplayer.view 35 | else 36 | Page.Game.Views.view spec model.windowSize game 37 | |> Html.map GameMsg 38 | 39 | Route.NewRoom newRoom -> 40 | if noMultiplayer then 41 | Views.NoMultiplayer.view 42 | else 43 | Page.NewRoom.Views.view newRoom 44 | |> Html.map NewRoomMsg 45 | 46 | Route.NotFound -> 47 | Page.NotFound.Views.view 48 | 49 | Route.NotOnBaseRoute -> 50 | div [] 51 | [ p [] [ text "Not on configured base path. Redirecting.." ] 52 | , p [] 53 | [ text "If not redirected in a couple of seconds, " 54 | , a [ href spec.basePath ] [ text "click here" ] 55 | , text "." 56 | ] 57 | ] 58 | 59 | Route.Tutorial tutorial -> 60 | Page.Tutorial.Views.view spec model.windowSize tutorial 61 | |> Html.map TutorialMsg 62 | in 63 | Views.Layout.view spec.inlineStyle isHome spec.unicodeIcon [ content ] 64 | -------------------------------------------------------------------------------- /site/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Time 4 | import AnimationFrame 5 | import Html exposing (Html, div, h1, h2, p, a, text, program, node, footer, main_, header) 6 | import Html.Attributes exposing (class, href) 7 | import Views.Logo 8 | import Styles exposing (cssText, CssClasses(..), localClass) 9 | 10 | 11 | main : Program Never Model Msg 12 | main = 13 | program 14 | { view = view 15 | , update = update 16 | , init = init 17 | , subscriptions = subscriptions 18 | } 19 | 20 | 21 | games : List String 22 | games = 23 | [ "lettero" 24 | , "counterclockwooze" 25 | , "spacecraterball" 26 | , "the-capitalist" 27 | , "fast-and-moebius" 28 | ] 29 | 30 | 31 | type alias Model = 32 | { time : Time.Time 33 | } 34 | 35 | 36 | type Msg 37 | = Tick Time.Time 38 | 39 | 40 | init : ( Model, Cmd Msg ) 41 | init = 42 | ( Model 0, Cmd.none ) 43 | 44 | 45 | subscriptions : Model -> Sub Msg 46 | subscriptions model = 47 | AnimationFrame.times Tick 48 | 49 | 50 | update : Msg -> Model -> ( Model, Cmd Msg ) 51 | update msg model = 52 | case msg of 53 | Tick time -> 54 | ( { model | time = time }, Cmd.none ) 55 | 56 | 57 | view : Model -> Html msg 58 | view model = 59 | div [ localClass [ Root ] ] <| 60 | [ node "style" [] [ cssText |> text ] 61 | , header [] 62 | [ Views.Logo.animatedView 1 model.time 63 | , h1 [] [ text "elm-gameroom" ] 64 | , p [] [ text "framework for reliably custom multiplayer games" ] 65 | ] 66 | , main_ [] 67 | [ p [] [ text "hey, feel like playing? here is a collection of multiplayer games written in a few hundred lines each in the comfortable, trusted hands of ElmLang:" ] 68 | , div [] <| 69 | List.map 70 | (\game -> 71 | a 72 | [ localClass [ Link ] 73 | , href ("/" ++ game) 74 | ] 75 | [ text game ] 76 | ) 77 | games 78 | ] 79 | , footer [] 80 | [ p [] 81 | [ text "find the project on " 82 | , a 83 | [ localClass [ SimpleLink ] 84 | , href "http://package.elm-lang.org/packages/peterszerzo/elm-gameroom/latest" 85 | ] 86 | [ text "GitHub" ] 87 | , text ". feel free to open a new pull request if you have game ideas" 88 | ] 89 | ] 90 | ] 91 | -------------------------------------------------------------------------------- /src/Data/Player.elm: -------------------------------------------------------------------------------- 1 | module Data.Player exposing (..) 2 | 3 | import Dict 4 | import Json.Decode as JD 5 | import Json.Encode as JE 6 | import Data.Guess as Guess 7 | 8 | 9 | type alias PlayerId = 10 | String 11 | 12 | 13 | type alias Player guess = 14 | { id : String 15 | , roomId : String 16 | , isReady : Bool 17 | , score : Int 18 | , guess : 19 | Maybe (Guess.Guess guess) 20 | } 21 | 22 | 23 | type alias Players guess = 24 | Dict.Dict String (Player guess) 25 | 26 | 27 | create : String -> String -> Player guess 28 | create id roomId = 29 | { id = id 30 | , roomId = roomId 31 | , isReady = False 32 | , score = 0 33 | , guess = Nothing 34 | } 35 | 36 | 37 | extractGuesses : List ( String, Player guess ) -> List ( String, guess ) 38 | extractGuesses players = 39 | case players of 40 | [] -> 41 | [] 42 | 43 | ( playerId, player ) :: tail -> 44 | (case player.guess of 45 | Just guess -> 46 | [ ( playerId, guess.value ) ] 47 | 48 | Nothing -> 49 | [] 50 | ) 51 | ++ (extractGuesses tail) 52 | 53 | 54 | 55 | -- Encoders 56 | 57 | 58 | encoder : (guess -> JE.Value) -> (Player guess -> JE.Value) 59 | encoder guessEncoder player = 60 | JE.object <| 61 | [ ( "id", JE.string player.id ) 62 | , ( "roomId", JE.string player.roomId ) 63 | , ( "isReady", JE.bool player.isReady ) 64 | , ( "score", JE.int player.score ) 65 | ] 66 | ++ (player.guess 67 | |> Maybe.map 68 | (\guess -> 69 | [ ( "guess" 70 | , JE.object 71 | [ ( "value", guessEncoder guess.value ) 72 | , ( "madeAt", JE.float guess.madeAt ) 73 | ] 74 | ) 75 | ] 76 | ) 77 | |> Maybe.withDefault [] 78 | ) 79 | 80 | 81 | collectionEncoder : (guess -> JE.Value) -> (Players guess -> JE.Value) 82 | collectionEncoder guessEncoder players = 83 | players 84 | |> Dict.toList 85 | |> List.map (\( key, player ) -> ( key, encoder guessEncoder player )) 86 | |> JE.object 87 | 88 | 89 | 90 | -- Decoders 91 | 92 | 93 | decoder : JD.Decoder guess -> JD.Decoder (Player guess) 94 | decoder guessDecoder = 95 | JD.map5 Player 96 | (JD.field "id" JD.string) 97 | (JD.field "roomId" JD.string) 98 | (JD.field "isReady" JD.bool) 99 | (JD.field "score" JD.int) 100 | (JD.maybe (JD.field "guess" (Guess.decoder guessDecoder))) 101 | -------------------------------------------------------------------------------- /src/Views/Layout.elm: -------------------------------------------------------------------------------- 1 | module Views.Layout exposing (view, css) 2 | 3 | import Html exposing (Html, div, node, p, a, text) 4 | import Html.CssHelpers 5 | import Css exposing (Stylesheet, stylesheet, position, fixed, top, px, bottom, left, right, backgroundColor, hex) 6 | import Css.Namespace exposing (namespace) 7 | import Css.File exposing (compile) 8 | import Messages 9 | import Views.Attribution 10 | import Views.Attribution.Styles 11 | import Views.Footer.Styles 12 | import Views.Header 13 | import Views.Header.Styles 14 | import Views.Notification.Styles 15 | import Views.Scoreboard.Styles 16 | import Views.NoMultiplayer.Styles 17 | import Views.Timer.Styles 18 | import Page.Game.Views.Styles 19 | import Page.Home.Views.Styles 20 | import Page.NewRoom.Views.Styles 21 | import Page.NotFound.Views.Styles 22 | import Page.Tutorial.Views.Styles 23 | import Styles.Shared 24 | import Styles.Constants exposing (white) 25 | 26 | 27 | type CssClasses 28 | = Root 29 | 30 | 31 | cssNamespace : String 32 | cssNamespace = 33 | "layout" 34 | 35 | 36 | styles : List Css.Snippet 37 | styles = 38 | [ Css.class Root 39 | [ position fixed 40 | , top (px 0) 41 | , bottom (px 0) 42 | , left (px 0) 43 | , right (px 0) 44 | , Css.displayFlex 45 | , Css.alignItems Css.center 46 | , Css.justifyContent Css.center 47 | , backgroundColor (hex white) 48 | ] 49 | ] 50 | |> namespace cssNamespace 51 | 52 | 53 | css : Stylesheet 54 | css = 55 | stylesheet 56 | (Styles.Shared.styles 57 | ++ styles 58 | ++ Views.Attribution.Styles.styles 59 | ++ Views.Footer.Styles.styles 60 | ++ Views.Header.Styles.styles 61 | ++ Views.Notification.Styles.styles 62 | ++ Views.NoMultiplayer.Styles.styles 63 | ++ Views.Scoreboard.Styles.styles 64 | ++ Views.Timer.Styles.styles 65 | ++ Page.Game.Views.Styles.styles 66 | ++ Page.Home.Views.Styles.styles 67 | ++ Page.NewRoom.Views.Styles.styles 68 | ++ Page.NotFound.Views.Styles.styles 69 | ++ Page.Tutorial.Views.Styles.styles 70 | ) 71 | 72 | 73 | class : List class -> Html.Attribute msg 74 | class = 75 | Html.CssHelpers.withNamespace cssNamespace |> .class 76 | 77 | 78 | view : Bool -> Bool -> String -> List (Html (Messages.Msg problem guess)) -> Html (Messages.Msg problem guess) 79 | view renderInlineStyle showAttribution icon children = 80 | div 81 | [ class [ Root ] 82 | ] 83 | <| 84 | (if renderInlineStyle then 85 | [ node "style" [] [ compile [ css ] |> .css |> text ] ] 86 | else 87 | [] 88 | ) 89 | ++ (if showAttribution then 90 | [ Views.Attribution.view ] 91 | else 92 | [ Views.Header.view icon 93 | ] 94 | ) 95 | ++ children 96 | -------------------------------------------------------------------------------- /src/Page/NewRoom/Update.elm: -------------------------------------------------------------------------------- 1 | module Page.NewRoom.Update exposing (..) 2 | 3 | import Page.NewRoom.Models exposing (Model, Status(..)) 4 | import Utils exposing (urlize) 5 | import Page.NewRoom.Messages exposing (Msg(..)) 6 | 7 | 8 | update : Msg -> Model -> ( Model, Bool, Maybe String ) 9 | update msg model = 10 | case msg of 11 | ChangeRoomId newRoomId -> 12 | let 13 | urlizedNewRoomId = 14 | urlize newRoomId 15 | 16 | isChanging = 17 | urlizedNewRoomId 18 | /= newRoomId 19 | in 20 | ( { model 21 | | roomId = urlizedNewRoomId 22 | , entriesUrlized = 23 | if isChanging then 24 | True 25 | else 26 | model.entriesUrlized 27 | } 28 | , False 29 | , Nothing 30 | ) 31 | 32 | ChangePlayerId index newPlayerId -> 33 | let 34 | urlizedNewPlayerId = 35 | urlize newPlayerId 36 | 37 | isChanging = 38 | urlizedNewPlayerId 39 | /= newPlayerId 40 | in 41 | ( { model 42 | | playerIds = 43 | List.indexedMap 44 | (\index_ oldValue -> 45 | if index_ == index then 46 | urlizedNewPlayerId 47 | else 48 | oldValue 49 | ) 50 | model.playerIds 51 | , entriesUrlized = 52 | if isChanging then 53 | True 54 | else 55 | model.entriesUrlized 56 | } 57 | , False 58 | , Nothing 59 | ) 60 | 61 | AddPlayer -> 62 | ( { model | playerIds = model.playerIds ++ [ "" ] } 63 | , False 64 | , Nothing 65 | ) 66 | 67 | RemovePlayer index -> 68 | ( { model | playerIds = (List.take index model.playerIds) ++ (List.drop (index + 1) model.playerIds) } 69 | , False 70 | , Nothing 71 | ) 72 | 73 | CreateRequest -> 74 | ( { model | status = Pending } 75 | , True 76 | , Nothing 77 | ) 78 | 79 | DismissUrlizeNotification -> 80 | ( { model | isUrlizedNotificationDismissed = True }, False, Nothing ) 81 | 82 | CreateResponse response -> 83 | ( model 84 | , False 85 | , Just 86 | ("/rooms/" ++ model.roomId ++ "/" ++ (model.playerIds |> List.head |> Maybe.withDefault "")) 87 | ) 88 | -------------------------------------------------------------------------------- /src/Styles/Mixins.elm: -------------------------------------------------------------------------------- 1 | module Styles.Mixins exposing (..) 2 | 3 | import Css exposing (..) 4 | import Styles.Constants exposing (..) 5 | 6 | 7 | -- Utilities 8 | 9 | 10 | centered : List Style 11 | centered = 12 | [ maxWidth (px 540) 13 | , maxHeight (pct 100) 14 | , overflowY auto 15 | , width (pct 100) 16 | , margin (px 0) 17 | , padding (px 20) 18 | , textAlign center 19 | ] 20 | 21 | 22 | standardBoxShadow : List Style 23 | standardBoxShadow = 24 | [ property "box-shadow" "0 0 18px rgba(0, 0, 0, 0.08), 0 0 6px rgba(0, 0, 0, 0.16)" 25 | ] 26 | 27 | 28 | intenseBoxShadow : List Style 29 | intenseBoxShadow = 30 | [ property "box-shadow" "0 0 30px rgba(0, 0, 0, 0.2), 0 0 10px rgba(0, 0, 0, 0.4)" 31 | ] 32 | 33 | 34 | 35 | -- Typography 36 | 37 | 38 | heroType : List Style 39 | heroType = 40 | [ fontSize (Css.rem 2.5) 41 | , property "font-weight" "300" 42 | , letterSpacing (Css.rem 0.05) 43 | , margin2 (px 10) auto 44 | ] 45 | 46 | 47 | subheroType : List Style 48 | subheroType = 49 | [ fontSize (Css.rem 2) 50 | , property "font-weight" "300" 51 | , letterSpacing (Css.rem 0.05) 52 | , margin3 (px 20) auto (px 20) 53 | ] 54 | 55 | 56 | headingType : List Style 57 | headingType = 58 | [ fontSize (Css.rem 1.5) 59 | , property "font-weight" "300" 60 | , letterSpacing (Css.rem 0.05) 61 | , margin3 (px 20) auto (px 40) 62 | ] 63 | 64 | 65 | bodyType : List Style 66 | bodyType = 67 | [ fontSize (Css.rem 1) 68 | , property "font-weight" "400" 69 | , letterSpacing (Css.rem 0.05) 70 | , lineHeight (num 1.5) 71 | , margin2 (px 10) auto 72 | ] 73 | 74 | 75 | 76 | -- Buttons 77 | 78 | 79 | button : List Style 80 | button = 81 | [ color (hex white) 82 | , cursor pointer 83 | , position relative 84 | , display inlineBlock 85 | , backgroundColor (hex blue) 86 | , fontSize (Css.rem 1) 87 | , letterSpacing (Css.rem 0.05) 88 | , margin (px 10) 89 | , padding2 (px 8) (px 16) 90 | , textDecoration none 91 | , borderRadius (px standardBorderRadius) 92 | , border (px 0) 93 | , outline none 94 | , border3 (px 1) solid (hex blue) 95 | , property "transition" "all 0.3s" 96 | , after 97 | [ property "content" "' '" 98 | , position absolute 99 | , top (px 0) 100 | , left (px 0) 101 | , bottom (px 0) 102 | , right (px 0) 103 | , property "transition" "background-color 0.3s" 104 | ] 105 | , hover 106 | [ after 107 | [ property "background-color" "rgba(255, 255, 255, 0.1)" 108 | ] 109 | ] 110 | , focus 111 | intenseBoxShadow 112 | ] 113 | ++ standardBoxShadow 114 | 115 | 116 | buttonDisabled : List Style 117 | buttonDisabled = 118 | [ borderColor (hex darkGrey) 119 | , color (hex darkGrey) 120 | , backgroundColor transparent 121 | , property "opacity" "0.6" 122 | , cursor initial 123 | , boxShadow none 124 | ] 125 | -------------------------------------------------------------------------------- /src/Views/Logo.elm: -------------------------------------------------------------------------------- 1 | module Views.Logo exposing (view, animatedView) 2 | 3 | import Html exposing (Html) 4 | import Time 5 | import Svg exposing (svg, polygon) 6 | import Svg.Attributes exposing (points, viewBox, width, height, stroke, strokeWidth, fill, transform) 7 | import Styles.Constants exposing (..) 8 | 9 | 10 | type alias Polygon = 11 | { coords : List ( Float, Float ) 12 | , color : String 13 | , transform : ( Float, Float ) 14 | , transformPhase : Float 15 | } 16 | 17 | 18 | polygons : List Polygon 19 | polygons = 20 | [ { coords = 21 | [ ( 30.0, -30.0 ) 22 | , ( 30.0, 3.33 ) 23 | , ( 13.33, -13.33 ) 24 | ] 25 | , color = cyan 26 | , transform = ( 0, 0 ) 27 | , transformPhase = 0 28 | } 29 | , { coords = 30 | [ ( 30.0, -30.0 ) 31 | , ( 13.33, -13.33 ) 32 | , ( -3.33, -30.0 ) 33 | ] 34 | , color = blue 35 | , transform = ( 0, 0 ) 36 | , transformPhase = 0 37 | } 38 | , { coords = 39 | [ ( 16.67, 1.71 ) 40 | , ( 16.67, 15.05 ) 41 | , ( 30.0, 15.05 ) 42 | ] 43 | , color = black 44 | , transform = ( 0, 1 ) 45 | , transformPhase = 4 * pi / 3 46 | } 47 | , { coords = 48 | [ ( 3.33, -17.55 ) 49 | , ( 16.67, -4.22 ) 50 | , ( 3.33, 9.12 ) 51 | ] 52 | , color = red 53 | , transform = ( 0, 3 ) 54 | , transformPhase = pi 55 | } 56 | , { coords = 57 | [ ( -3.33, 6.0 ) 58 | , ( -16.67, -7.33 ) 59 | , ( -30.0, 6.0 ) 60 | ] 61 | , color = blue 62 | , transform = ( 0, 1.5 ) 63 | , transformPhase = pi / 3 64 | } 65 | , { coords = 66 | [ ( -16.67, -14.16 ) 67 | , ( -3.33, -27.49 ) 68 | , ( -3.33, -1.17 ) 69 | ] 70 | , color = cyan 71 | , transform = ( 0, 1.6 ) 72 | , transformPhase = 2 * pi / 3 73 | } 74 | , { coords = 75 | [ ( -30.0, -3.33 ) 76 | , ( -30.0, -30.0 ) 77 | , ( -3.33, -30.0 ) 78 | ] 79 | , color = black 80 | , transform = ( 0, 0 ) 81 | , transformPhase = 0 82 | } 83 | ] 84 | 85 | 86 | viewPolygon : Float -> Time.Time -> Polygon -> Html msg 87 | viewPolygon amplitude time { coords, color, transform, transformPhase } = 88 | let 89 | sinTime = 90 | sin (time / 1000 + transformPhase) 91 | 92 | factor = 93 | amplitude * sinTime 94 | 95 | dx = 96 | factor * (Tuple.first transform) 97 | 98 | dy = 99 | factor * (Tuple.second transform) 100 | in 101 | polygon 102 | [ points 103 | (coords 104 | |> List.map 105 | (\( x, y ) -> 106 | (toString (x + dx)) ++ "," ++ (toString (-(y + dy))) 107 | ) 108 | |> String.join " " 109 | ) 110 | , stroke "#FFF" 111 | , strokeWidth "1.5" 112 | , fill ("#" ++ color) 113 | ] 114 | [] 115 | 116 | 117 | view : Html msg 118 | view = 119 | animatedView 0 0 120 | 121 | 122 | animatedView : Float -> Time.Time -> Html msg 123 | animatedView amplitude time = 124 | svg [ viewBox "-30 -30 60 60" ] 125 | (List.map (viewPolygon amplitude time) polygons) 126 | -------------------------------------------------------------------------------- /src/Page/NewRoom/Views.elm: -------------------------------------------------------------------------------- 1 | module Page.NewRoom.Views exposing (view) 2 | 3 | import Html exposing (Html, div, text, button, form, h1, h2, label, input, fieldset, span, ul, li, a, p) 4 | import Html.Attributes exposing (class, style, type_, value, id, for, href, placeholder, disabled) 5 | import Html.Events exposing (onClick, onInput, onSubmit) 6 | import Page.NewRoom.Models exposing (Model, Status(..)) 7 | import Page.NewRoom.Messages exposing (Msg(..)) 8 | import Page.NewRoom.Views.Styles exposing (CssClasses(..), localClass, localClassList) 9 | import Views.Loader 10 | import Views.Notification 11 | import Copy 12 | 13 | 14 | viewForm : Model -> List (Html Msg) 15 | viewForm model = 16 | let 17 | canSubmit = 18 | (String.length model.roomId > 0) 19 | && (model.playerIds |> List.map (\playerId -> String.length playerId > 0) |> List.all identity) 20 | in 21 | [ h2 [ localClass [ Title ] ] [ text Copy.newRoomPageTitle ] 22 | , p [] [ text Copy.newRoomFormIntro ] 23 | , form 24 | [ onSubmit CreateRequest 25 | ] 26 | [ label [ for "roomid" ] 27 | [ text "Room Id" 28 | , input 29 | [ id "roomid" 30 | , type_ "text" 31 | , onInput ChangeRoomId 32 | , placeholder "e.g. theroom" 33 | , value model.roomId 34 | ] 35 | [] 36 | ] 37 | , div [] 38 | (List.indexedMap 39 | (\index playerId -> 40 | let 41 | fieldId = 42 | "playerid-" ++ (toString index) 43 | in 44 | label [ for fieldId ] 45 | ([ text ("Player " ++ (toString (index + 1))) 46 | , input 47 | [ id fieldId 48 | , type_ "text" 49 | , onInput (ChangePlayerId index) 50 | , placeholder 51 | (if rem index 2 == 0 then 52 | "e.g. alfred" 53 | else 54 | "e.g. samantha" 55 | ) 56 | , value (List.drop index model.playerIds |> List.head |> Maybe.withDefault "") 57 | ] 58 | [] 59 | ] 60 | ++ (if List.length model.playerIds > 2 then 61 | [ button 62 | [ onClick (RemovePlayer index) 63 | ] 64 | [ text "✕" ] 65 | ] 66 | else 67 | [] 68 | ) 69 | ) 70 | ) 71 | model.playerIds 72 | ) 73 | , button 74 | [ localClass [ Button, FormButton ] 75 | , onClick AddPlayer 76 | ] 77 | [ text "Add player" ] 78 | , button 79 | [ localClassList [ ( Button, True ), ( FormButton, True ), ( ButtonDisabled, not canSubmit ) ] 80 | , type_ "submit" 81 | , disabled (not canSubmit) 82 | ] 83 | [ text "Create room" ] 84 | ] 85 | , Views.Notification.view 86 | (if not model.entriesUrlized || model.isUrlizedNotificationDismissed then 87 | Nothing 88 | else 89 | Just Copy.casualNamesWarning 90 | ) 91 | (Just DismissUrlizeNotification) 92 | ] 93 | 94 | 95 | view : Model -> Html Msg 96 | view model = 97 | div [ localClass [ Root ] ] 98 | (case model.status of 99 | Editing -> 100 | viewForm model 101 | 102 | Pending -> 103 | [ Views.Loader.view ] 104 | 105 | Error -> 106 | [ h2 [] [ text "There was an error creating your room :/" ] 107 | , p [] [ text "Please reload the page and try again." ] 108 | , a [ href "new", localClass [ Button ] ] [ text "Reload" ] 109 | ] 110 | ) 111 | -------------------------------------------------------------------------------- /src/Data/Spec.elm: -------------------------------------------------------------------------------- 1 | module Data.Spec exposing (..) 2 | 3 | {-| With the Spec object, you can define your game declaratively, specifying only what is unique to it. 4 | 5 | # The full spec 6 | @docs Spec 7 | 8 | # The view 9 | @docs View, Ticks, Copy 10 | 11 | # Game logic 12 | @docs ProblemGenerator 13 | 14 | # Data management 15 | @docs ProblemEncoder, ProblemDecoder, GuessEncoder, GuessDecoder 16 | -} 17 | 18 | import Html 19 | import Time 20 | import Random 21 | import Messages exposing (Msg) 22 | import Data.Ports as Ports 23 | import Json.Decode as Decode 24 | import Json.Encode as Encode 25 | import Gameroom.Context exposing (Context) 26 | 27 | 28 | type Setting problem guess 29 | = BasePath String 30 | | Name String 31 | | Subheading String 32 | | Instructions String 33 | | UnicodeIcon String 34 | | RoundDuration Time.Time 35 | | CooldownDuration Time.Time 36 | | ClearWinner Float 37 | | SetPorts (Ports.Ports (Msg problem guess)) 38 | | NoInlineStyle 39 | | NoPeripheralUi 40 | 41 | 42 | {-| Define the basic mechanics of a multiplayer game, all generalized over a type variable representing a `problem`, and one representing a `guess`. Each field in the record is documented separately in this module. 43 | -} 44 | type alias Spec problem guess = 45 | { view : Context guess -> problem -> Html.Html guess 46 | , evaluate : problem -> guess -> Float 47 | , problemGenerator : Random.Generator problem 48 | , problemEncoder : problem -> Encode.Value 49 | , problemDecoder : Decode.Decoder problem 50 | , guessEncoder : guess -> Encode.Value 51 | , guessDecoder : Decode.Decoder guess 52 | } 53 | 54 | 55 | {-| Augments the spec with data from options. 56 | -} 57 | type alias DetailedSpec problem guess = 58 | { basePath : String 59 | , unicodeIcon : String 60 | , name : String 61 | , subheading : String 62 | , instructions : String 63 | , roundDuration : Time.Time 64 | , cooldownDuration : Time.Time 65 | , clearWinnerEvaluation : Maybe Float 66 | , ports : Maybe (Ports.Ports (Msg problem guess)) 67 | , inlineStyle : Bool 68 | , peripheralUi : Bool 69 | , view : Context guess -> problem -> Html.Html guess 70 | , evaluate : problem -> guess -> Float 71 | , problemGenerator : Random.Generator problem 72 | , problemEncoder : problem -> Encode.Value 73 | , problemDecoder : Decode.Decoder problem 74 | , guessEncoder : guess -> Encode.Value 75 | , guessDecoder : Decode.Decoder guess 76 | } 77 | 78 | 79 | buildDetailedSpec : List (Setting problem guess) -> Spec problem guess -> DetailedSpec problem guess 80 | buildDetailedSpec options spec = 81 | List.foldl 82 | (\option spec -> 83 | case option of 84 | BasePath basePath -> 85 | let 86 | baseSlug = 87 | basePath 88 | |> (\path_ -> 89 | -- Remove leading slash 90 | if String.left 1 path_ == "/" then 91 | String.dropLeft 1 path_ 92 | else 93 | path_ 94 | ) 95 | |> (\path_ -> 96 | -- Remove trailing slash 97 | if String.right 1 path_ == "/" then 98 | String.dropRight 1 path_ 99 | else 100 | path_ 101 | ) 102 | in 103 | { spec | basePath = "/" ++ baseSlug } 104 | 105 | Name name -> 106 | { spec | name = name } 107 | 108 | Subheading subheading -> 109 | { spec | subheading = subheading } 110 | 111 | Instructions instructions -> 112 | { spec | instructions = instructions } 113 | 114 | UnicodeIcon icon -> 115 | { spec | unicodeIcon = icon } 116 | 117 | RoundDuration duration -> 118 | { spec | roundDuration = duration } 119 | 120 | CooldownDuration duration -> 121 | { spec | cooldownDuration = duration } 122 | 123 | ClearWinner maxEvaluation -> 124 | { spec | clearWinnerEvaluation = Just maxEvaluation } 125 | 126 | SetPorts p -> 127 | { spec | ports = Just p } 128 | 129 | NoInlineStyle -> 130 | { spec | inlineStyle = False } 131 | 132 | NoPeripheralUi -> 133 | { spec | peripheralUi = False } 134 | ) 135 | { basePath = "/" 136 | , unicodeIcon = "\x1F3D3" 137 | , name = "Game" 138 | , subheading = "A great game to play with your friends" 139 | , instructions = "Win the game!" 140 | , roundDuration = 4 * Time.second 141 | , cooldownDuration = 2 * Time.second 142 | , clearWinnerEvaluation = Nothing 143 | , ports = Nothing 144 | , view = spec.view 145 | , inlineStyle = True 146 | , peripheralUi = True 147 | , evaluate = spec.evaluate 148 | , problemGenerator = spec.problemGenerator 149 | , problemEncoder = spec.problemEncoder 150 | , problemDecoder = spec.problemDecoder 151 | , guessEncoder = spec.guessEncoder 152 | , guessDecoder = spec.guessDecoder 153 | } 154 | options 155 | 156 | 157 | sendToPort : DetailedSpec problem guess -> (Encode.Value -> Cmd (Msg problem guess)) 158 | sendToPort spec = 159 | spec.ports 160 | |> Maybe.map .outgoing 161 | |> Maybe.withDefault (always Cmd.none) 162 | -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (..) 2 | 3 | import Navigation 4 | import Random 5 | import Messages exposing (..) 6 | import Models exposing (Model) 7 | import Data.Route as Route 8 | import Data.Room as Room 9 | import Data.Spec as Spec 10 | import Data.IncomingMessage as InMsg 11 | import Data.OutgoingMessage as OutgoingMessage 12 | import Page.Game.Messages 13 | import Page.Game.Update 14 | import Page.NewRoom.Messages 15 | import Page.NewRoom.Update 16 | import Page.Tutorial.Messages 17 | import Page.Tutorial.Update 18 | 19 | 20 | cmdOnRouteChange : 21 | Spec.DetailedSpec problem guess 22 | -> Route.Route problem guess 23 | -> Maybe (Route.Route problem guess) 24 | -> Cmd (Msg problem guess) 25 | cmdOnRouteChange spec route prevRoute = 26 | let 27 | sendToPort = 28 | Spec.sendToPort spec 29 | in 30 | case ( route, prevRoute ) of 31 | ( Route.Game game, _ ) -> 32 | OutgoingMessage.SubscribeToRoom game.roomId 33 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder 34 | |> sendToPort 35 | 36 | ( _, Just (Route.Game prevGame) ) -> 37 | -- Unsubscribe from a previous room 38 | OutgoingMessage.UnsubscribeFromRoom prevGame.roomId 39 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder 40 | |> sendToPort 41 | 42 | ( Route.Tutorial _, _ ) -> 43 | Random.generate (Messages.TutorialMsg << Page.Tutorial.Messages.ReceiveProblem) spec.problemGenerator 44 | 45 | ( _, _ ) -> 46 | Cmd.none 47 | 48 | 49 | navigationNewUrl : String -> String -> Cmd (Msg problem guess) 50 | navigationNewUrl basePath newUrl = 51 | basePath 52 | ++ newUrl 53 | |> Navigation.newUrl 54 | 55 | 56 | update : 57 | Spec.DetailedSpec problem guess 58 | -> Msg problem guess 59 | -> Model problem guess 60 | -> ( Model problem guess, Cmd (Msg problem guess) ) 61 | update spec msg model = 62 | let 63 | sendToPort = 64 | Spec.sendToPort spec 65 | in 66 | case ( model.route, msg ) of 67 | ( _, Navigate newUrl ) -> 68 | ( model 69 | , navigationNewUrl spec.basePath newUrl 70 | ) 71 | 72 | ( oldRoute, ChangeRoute route ) -> 73 | ( { model | route = route } 74 | , Cmd.batch 75 | [ cmdOnRouteChange spec route (Just oldRoute) 76 | , if route == Route.NotOnBaseRoute then 77 | (Navigation.newUrl spec.basePath) 78 | else 79 | Cmd.none 80 | ] 81 | ) 82 | 83 | ( _, Resize newWindowSize ) -> 84 | ( { model | windowSize = newWindowSize }, Cmd.none ) 85 | 86 | ( Route.Game game, GameMsg gameMsg ) -> 87 | let 88 | ( newGame, commandValues, generateNewRound ) = 89 | Page.Game.Update.update spec gameMsg game 90 | in 91 | ( { model | route = Route.Game newGame } 92 | , Cmd.batch <| 93 | (List.map sendToPort commandValues) 94 | ++ (if generateNewRound then 95 | [ (Random.generate (\pb -> Messages.GameMsg (Page.Game.Messages.ReceiveNewProblem pb)) spec.problemGenerator) ] 96 | else 97 | [] 98 | ) 99 | ) 100 | 101 | ( Route.Game game, IncomingMessage (InMsg.RoomUpdated room) ) -> 102 | let 103 | ( newGame, commandValues, generateNewRound ) = 104 | Page.Game.Update.update spec (Page.Game.Messages.ReceiveUpdate room) game 105 | in 106 | ( { model | route = Route.Game newGame } 107 | , Cmd.batch <| 108 | (List.map sendToPort commandValues) 109 | ++ (if generateNewRound then 110 | [ (Random.generate (\pb -> Messages.GameMsg (Page.Game.Messages.ReceiveNewProblem pb)) spec.problemGenerator) ] 111 | else 112 | [] 113 | ) 114 | ) 115 | 116 | ( Route.NewRoom newRoom, NewRoomMsg newRoomMsg ) -> 117 | let 118 | ( newNewRoom, sendSaveCommand, newUrl ) = 119 | (Page.NewRoom.Update.update newRoomMsg newRoom) 120 | in 121 | ( { model | route = Route.NewRoom newNewRoom } 122 | , if sendSaveCommand then 123 | Room.create newRoom.roomId newRoom.playerIds 124 | |> OutgoingMessage.CreateRoom 125 | |> OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder 126 | |> sendToPort 127 | else 128 | Cmd.none 129 | ) 130 | 131 | ( Route.NewRoom newRoom, IncomingMessage (InMsg.RoomCreated room) ) -> 132 | let 133 | ( newModel, _, newUrl ) = 134 | Page.NewRoom.Update.update (Page.NewRoom.Messages.CreateResponse "") newRoom 135 | in 136 | ( { model 137 | | route = 138 | Route.NewRoom newRoom 139 | } 140 | , newUrl 141 | |> Maybe.map (navigationNewUrl spec.basePath) 142 | |> Maybe.withDefault Cmd.none 143 | ) 144 | 145 | ( Route.Tutorial tutorial, TutorialMsg msg ) -> 146 | let 147 | ( newTutorial, generateNewRound ) = 148 | Page.Tutorial.Update.update spec msg tutorial 149 | in 150 | ( { model | route = Route.Tutorial newTutorial } 151 | , if generateNewRound then 152 | Random.generate (Messages.TutorialMsg << Page.Tutorial.Messages.ReceiveProblem) spec.problemGenerator 153 | else 154 | Cmd.none 155 | ) 156 | 157 | ( _, _ ) -> 158 | ( model, Cmd.none ) 159 | -------------------------------------------------------------------------------- /tests/Tests/Data/Room.elm: -------------------------------------------------------------------------------- 1 | module Tests.Data.Room exposing (..) 2 | 3 | import Test exposing (..) 4 | import Dict 5 | import Expect 6 | import Data.Room as Room 7 | 8 | 9 | tests : Test 10 | tests = 11 | describe "Room" 12 | [ describe "allPlayersReady" 13 | [ test "returns True on empty players" <| 14 | \() -> 15 | Room.allPlayersReady 16 | { id = "123" 17 | , host = "456" 18 | , round = Nothing 19 | , players = Dict.empty 20 | } 21 | |> Expect.equal True 22 | ] 23 | , describe "updatePreservingLocalGuesses" 24 | [ test "preserves if round did not change" <| 25 | \() -> 26 | (Room.updatePreservingLocalGuesses 27 | { id = "123" 28 | , host = "456" 29 | , round = Just { no = 1, problem = "1234" } 30 | , players = 31 | Dict.fromList 32 | [ ( "player1" 33 | , { id = "player1" 34 | , roomId = "123" 35 | , isReady = True 36 | , score = 0 37 | , guess = Nothing 38 | } 39 | ) 40 | , ( "player2" 41 | , { id = "player2" 42 | , roomId = "123" 43 | , isReady = True 44 | , score = 0 45 | , guess = 46 | Nothing 47 | } 48 | ) 49 | ] 50 | } 51 | { id = "123" 52 | , host = "456" 53 | , round = Just { no = 1, problem = "1234" } 54 | , players = 55 | Dict.fromList 56 | [ ( "player1" 57 | , { id = "player1" 58 | , roomId = "123" 59 | , isReady = True 60 | , score = 0 61 | , guess = Nothing 62 | } 63 | ) 64 | , ( "player2" 65 | , { id = "player2" 66 | , roomId = "123" 67 | , isReady = True 68 | , score = 0 69 | , guess = 70 | Just 71 | { value = 0 72 | , madeAt = 100 73 | } 74 | } 75 | ) 76 | ] 77 | } 78 | |> .players 79 | |> Dict.get "player2" 80 | |> Maybe.andThen .guess 81 | |> Maybe.map .value 82 | |> Expect.equal (Just 0) 83 | ) 84 | , test "does not preserve if round changed" <| 85 | \() -> 86 | (Room.updatePreservingLocalGuesses 87 | { id = "123" 88 | , host = "456" 89 | , round = Just { no = 2, problem = "1324" } 90 | , players = 91 | Dict.fromList 92 | [ ( "player1" 93 | , { id = "player1" 94 | , roomId = "123" 95 | , isReady = True 96 | , score = 0 97 | , guess = Nothing 98 | } 99 | ) 100 | , ( "player2" 101 | , { id = "player2" 102 | , roomId = "123" 103 | , isReady = True 104 | , score = 0 105 | , guess = 106 | Nothing 107 | } 108 | ) 109 | ] 110 | } 111 | { id = "123" 112 | , host = "456" 113 | , round = Just { no = 1, problem = "1234" } 114 | , players = 115 | Dict.fromList 116 | [ ( "player1" 117 | , { id = "player1" 118 | , roomId = "123" 119 | , isReady = True 120 | , score = 0 121 | , guess = Nothing 122 | } 123 | ) 124 | , ( "player2" 125 | , { id = "player2" 126 | , roomId = "123" 127 | , isReady = True 128 | , score = 0 129 | , guess = 130 | Just 131 | { value = 0 132 | , madeAt = 100 133 | } 134 | } 135 | ) 136 | ] 137 | } 138 | |> .players 139 | |> Dict.get "player2" 140 | |> Maybe.andThen .guess 141 | |> Maybe.map .value 142 | |> Expect.equal Nothing 143 | ) 144 | ] 145 | ] 146 | -------------------------------------------------------------------------------- /examples/lettero/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Random 4 | import Json.Encode as JE 5 | import Json.Decode as JD 6 | import Html exposing (Html, div, text, span) 7 | import Html.Attributes exposing (class, style) 8 | import Html.Events exposing (onClick) 9 | import Gameroom exposing (..) 10 | 11 | 12 | -- Types 13 | 14 | 15 | type alias Problem = 16 | { word : String 17 | , startAngle : Float 18 | } 19 | 20 | 21 | type alias Guess = 22 | Int 23 | 24 | 25 | 26 | -- Spec 27 | 28 | 29 | spec : Spec Problem Guess 30 | spec = 31 | { view = 32 | (\context problem -> 33 | div 34 | [ style 35 | [ ( "position", "absolute" ) 36 | , ( "width", "75vmin" ) 37 | , ( "height", "75vmin" ) 38 | , ( "top", "50%" ) 39 | , ( "left", "50%" ) 40 | , ( "transform", "scale(1.0, 1.0) translate3d(-50%, -50%, 0) rotate(" ++ (context.roundTime / 80 |> toString) ++ "deg)" ) 41 | ] 42 | ] 43 | (problem.word 44 | |> String.toList 45 | |> List.indexedMap 46 | (\index character -> 47 | let 48 | angle = 49 | (index |> toFloat) 50 | / (problem.word 51 | |> String.length 52 | |> toFloat 53 | ) 54 | * 2 55 | * pi 56 | + problem.startAngle 57 | 58 | isGuessedBySelf = 59 | context.ownGuess == (Just index) 60 | 61 | isMarkedCorrect = 62 | (index == 0) && (isGuessedBySelf || context.isRoundOver) 63 | 64 | isGuessedByOthers = 65 | context.opponentGuesses 66 | |> List.filter (\( playerId, guess ) -> guess == index) 67 | |> List.head 68 | |> Maybe.map (\( playerId, guess ) -> guess == index) 69 | |> Maybe.withDefault False 70 | in 71 | span 72 | [ style 73 | ([ ( "position", "absolute" ) 74 | , ( "display", "block" ) 75 | , ( "cursor", "pointer" ) 76 | , ( "font-size", "calc(3vh + 3vw)" ) 77 | , ( "width", "calc(4.5vh + 4.5vw)" ) 78 | , ( "height", "calc(4.5vh + 4.5vw)" ) 79 | , ( "padding-top", "calc(0.6vh + 0.6vw)" ) 80 | , ( "line-height", "1" ) 81 | , ( "border-radius", "50%" ) 82 | , ( "text-align", "center" ) 83 | , ( "border", "2px solid transparent" ) 84 | , ( "top", ((1 - sin angle) * 50 |> toString) ++ "%" ) 85 | , ( "left", ((1 - cos angle) * 50 |> toString) ++ "%" ) 86 | , ( "transform", "translate3d(-50%, -50%, 0) rotate(" ++ ((angle * 180 / pi - 90) |> toString) ++ "deg)" ) 87 | , ( "text-transform", "uppercase" ) 88 | ] 89 | ++ (if isMarkedCorrect then 90 | [ ( "border", "2px solid black" ) 91 | , ( "background-color", "black" ) 92 | , ( "color", "white" ) 93 | ] 94 | else if (isGuessedBySelf || (isGuessedByOthers && context.isRoundOver)) then 95 | [ ( "border", "2px solid black" ) 96 | ] 97 | else 98 | [] 99 | ) 100 | ) 101 | , onClick index 102 | ] 103 | [ text (String.fromChar character) ] 104 | ) 105 | ) 106 | ) 107 | , evaluate = 108 | (\problem guess -> 109 | if (guess == 0) then 110 | 100 111 | else 112 | 0 113 | ) 114 | , problemGenerator = 115 | Random.map2 Problem 116 | (generatorFromList "perrywinkle" <| 117 | [ "gingerberry", "apples", "vineyard", "is", "tablespoon", "cutlery", "laborer" ] 118 | ++ [ "grenade", "coaster", "mahogany", "burrito", "cilantro", "kettle" ] 119 | ++ [ "revenue", "stool", "ginger", "electricity", "purple", "backpack" ] 120 | ++ [ "phone", "bill", "family", "cucumber", "terrific", "towel", "tower" ] 121 | ++ [ "lightbulb", "leaf", "loaf", "parrot", "rack", "rope", "poor", "strap" ] 122 | ++ [ "faucet", "lipstick", "grapefruit", "pickle", "woodpecker" ] 123 | ) 124 | (Random.float 0 (2 * pi)) 125 | , guessEncoder = JE.int 126 | , guessDecoder = JD.int 127 | , problemEncoder = 128 | (\problem -> 129 | JE.object 130 | [ ( "word", JE.string problem.word ) 131 | , ( "startAngle", JE.float problem.startAngle ) 132 | ] 133 | ) 134 | , problemDecoder = 135 | JD.map2 Problem 136 | (JD.field "word" JD.string) 137 | (JD.field "startAngle" JD.float) 138 | } 139 | 140 | 141 | 142 | -- Ports 143 | 144 | 145 | port outgoing : JE.Value -> Cmd msg 146 | 147 | 148 | port incoming : (JE.Value -> msg) -> Sub msg 149 | 150 | 151 | 152 | -- Main 153 | 154 | 155 | main : Program Never (Model Problem Guess) (Msg Problem Guess) 156 | main = 157 | gameWith 158 | [ basePath "/lettero" 159 | , unicodeIcon "✏️" 160 | , name "Lettero" 161 | , subheading "A mildly frustrating wordgame!" 162 | , instructions "There is a word in there somewhere - tap its first letter!" 163 | , clearWinner 100 164 | , responsiblePorts { incoming = incoming, outgoing = outgoing } 165 | ] 166 | spec 167 | -------------------------------------------------------------------------------- /examples/counterclockwooze/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Random 4 | import Json.Encode as JE 5 | import Json.Decode as JD 6 | import Html exposing (Html, div, text, span) 7 | import Html.Attributes exposing (class, style, attribute) 8 | import Html.Events exposing (onClick) 9 | import Svg exposing (polygon, svg, g, circle) 10 | import Svg.Attributes exposing (width, height, viewBox, points, transform, r, cx, cy, fill, stroke, strokeWidth) 11 | import Gameroom exposing (..) 12 | 13 | 14 | -- Types 15 | 16 | 17 | type alias Point = 18 | { x : Float 19 | , y : Float 20 | } 21 | 22 | 23 | type alias Problem = 24 | List Point 25 | 26 | 27 | type alias Guess = 28 | Int 29 | 30 | 31 | 32 | -- Spec 33 | 34 | 35 | grey : String 36 | grey = 37 | "#222226" 38 | 39 | 40 | spec : Spec Problem Guess 41 | spec = 42 | { view = 43 | (\context problem -> 44 | div 45 | [ class "spinning-shapes-container" 46 | , style 47 | [ ( "position", "absolute" ) 48 | , ( "width", "80vmin" ) 49 | , ( "height", "80vmin" ) 50 | , ( "top", "50%" ) 51 | , ( "left", "50%" ) 52 | , ( "transform", "scale(1.0, 1.0) translate3d(-50%, -50%, 0)" ) 53 | ] 54 | ] 55 | [ svg [ viewBox "0 0 1000 1000" ] 56 | (List.indexedMap 57 | (\index { x, y } -> 58 | let 59 | isCorrect = 60 | (index == 0) 61 | 62 | isGuessedBySelf = 63 | context.ownGuess == (Just index) 64 | 65 | isMarkedCorrect = 66 | isCorrect && (isGuessedBySelf || context.isRoundOver) 67 | 68 | isGuessedByOthers = 69 | context.opponentGuesses 70 | |> List.filter (\( playerId, guess ) -> guess == index) 71 | |> List.head 72 | |> Maybe.map (\( playerId, guess ) -> guess == index) 73 | |> Maybe.withDefault False 74 | 75 | translateString = 76 | ("translate(" ++ (toString (x * 800 + 100)) ++ "," ++ (toString (y * 800 + 100)) ++ ")") 77 | in 78 | g [ transform translateString ] 79 | [ circle 80 | [ r "80" 81 | , cx "0" 82 | , cy "0" 83 | , fill 84 | (if (isCorrect && (context.isRoundOver || isGuessedBySelf)) then 85 | grey 86 | else 87 | "none" 88 | ) 89 | , stroke 90 | (if (isGuessedBySelf || (isGuessedByOthers && context.isRoundOver)) then 91 | grey 92 | else 93 | "rgba(255, 255, 255, 0)" 94 | ) 95 | , strokeWidth "2" 96 | ] 97 | [] 98 | , polygon 99 | [ points "-50,-28.8 50,-28.8 0,57.7" 100 | , fill 101 | (if (isCorrect && (context.isRoundOver || isGuessedBySelf)) then 102 | "white" 103 | else 104 | grey 105 | ) 106 | , attribute "transform" 107 | ("rotate(" 108 | ++ ((context.roundTime / 16) 109 | |> (*) 0.5 110 | |> (*) 111 | (if index == 0 then 112 | 1 113 | else 114 | -1 115 | ) 116 | |> toString 117 | ) 118 | ++ ")" 119 | ) 120 | , onClick index 121 | ] 122 | [] 123 | ] 124 | ) 125 | problem 126 | ) 127 | ] 128 | ) 129 | , evaluate = 130 | (\problem guess -> 131 | if guess == 0 then 132 | 100 133 | else 134 | 0 135 | ) 136 | , problemGenerator = 137 | Random.list 10 (Random.map2 Point (Random.float 0 1) (Random.float 0 1)) 138 | , guessEncoder = JE.int 139 | , guessDecoder = JD.int 140 | , problemEncoder = 141 | List.map 142 | (\triangle -> 143 | JE.object 144 | [ ( "x", JE.float triangle.x ) 145 | , ( "y", JE.float triangle.y ) 146 | ] 147 | ) 148 | >> JE.list 149 | , problemDecoder = 150 | JD.list 151 | (JD.map2 Point 152 | (JD.field "x" JD.float) 153 | (JD.field "y" JD.float) 154 | ) 155 | } 156 | 157 | 158 | 159 | -- Config 160 | 161 | 162 | port outgoing : JE.Value -> Cmd msg 163 | 164 | 165 | port incoming : (JE.Value -> msg) -> Sub msg 166 | 167 | 168 | 169 | -- Program 170 | 171 | 172 | main : Program Never (Model Problem Guess) (Msg Problem Guess) 173 | main = 174 | gameWith 175 | [ basePath "/counterclockwooze" 176 | , unicodeIcon "🕒" 177 | , name "Counterclockwooze" 178 | , subheading "A dizzying geometric game for the family" 179 | , instructions "One of the shapes spins the other way - care to find it?" 180 | , clearWinner 100 181 | , responsiblePorts { outgoing = outgoing, incoming = incoming } 182 | ] 183 | spec 184 | -------------------------------------------------------------------------------- /examples/the-capitalist/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Random 4 | import Html exposing (Html, div, text, span, h1, ul, li) 5 | import Html.Attributes exposing (class, style, attribute) 6 | import Html.Events exposing (onClick) 7 | import Json.Encode as Encode 8 | import Json.Decode as Decode 9 | import Gameroom exposing (..) 10 | import Gameroom.Context exposing (Context) 11 | 12 | 13 | -- Main 14 | 15 | 16 | main : Program Never (Model Problem Guess) (Msg Problem Guess) 17 | main = 18 | gameWith 19 | [ basePath "/the-capitalist" 20 | , unicodeIcon "💰" 21 | , name "The Capitalist" 22 | , subheading "Oh, not that kind, though, more like a person who knows the capital of a lot of countries.." 23 | , instructions "Find the capital of the country!" 24 | , clearWinner 100 25 | , responsiblePorts { outgoing = outgoing, incoming = incoming } 26 | ] 27 | spec 28 | 29 | 30 | 31 | -- Types, encoders and decoders 32 | 33 | 34 | type alias Problem = 35 | { question : String 36 | , answers : List String 37 | , correct : Int 38 | } 39 | 40 | 41 | problemEncoder : Problem -> Encode.Value 42 | problemEncoder problem = 43 | Encode.object 44 | [ ( "question", Encode.string problem.question ) 45 | , ( "answers" 46 | , List.map Encode.string problem.answers 47 | |> Encode.list 48 | ) 49 | , ( "correct", Encode.int problem.correct ) 50 | ] 51 | 52 | 53 | problemDecoder : Decode.Decoder Problem 54 | problemDecoder = 55 | Decode.map3 Problem 56 | (Decode.field "question" Decode.string) 57 | (Decode.field "answers" (Decode.list Decode.string)) 58 | (Decode.field "correct" Decode.int) 59 | 60 | 61 | type alias Guess = 62 | Int 63 | 64 | 65 | guessEncoder : Guess -> Encode.Value 66 | guessEncoder = 67 | Encode.int 68 | 69 | 70 | guessDecoder : Decode.Decoder Guess 71 | guessDecoder = 72 | Decode.int 73 | 74 | 75 | 76 | -- Game spec 77 | 78 | 79 | spec : Spec Problem Guess 80 | spec = 81 | { view = view 82 | , evaluate = evaluate 83 | , problemGenerator = problemGenerator 84 | , guessEncoder = guessEncoder 85 | , guessDecoder = guessDecoder 86 | , problemEncoder = problemEncoder 87 | , problemDecoder = problemDecoder 88 | } 89 | 90 | 91 | view : Context Guess -> Problem -> Html Guess 92 | view context problem = 93 | -- not used, only here to show a more concise version of the game view 94 | div [] 95 | [ div [] 96 | [ h1 [] [ text problem.question ] 97 | , ul [] 98 | (List.indexedMap 99 | (\index answer -> 100 | -- events map to raw guesses (in this case, an Int) 101 | li [ onClick index ] [ text answer ] 102 | ) 103 | problem.answers 104 | ) 105 | ] 106 | ] 107 | 108 | 109 | styledView : Context Guess -> Problem -> Html Guess 110 | styledView context problem = 111 | div [ style containerStyle ] 112 | [ div [ style contentStyle ] 113 | [ h1 [] [ text problem.question ] 114 | , ul 115 | [ style listStyle 116 | ] 117 | (List.indexedMap 118 | (\index answer -> 119 | let 120 | isGuessedBySelf = 121 | context.ownGuess == (Just index) 122 | 123 | isMarkedCorrect = 124 | (index == problem.correct) && (isGuessedBySelf || context.isRoundOver) 125 | in 126 | li 127 | [ onClick index 128 | , style <| 129 | listItemBaseStyle isGuessedBySelf isMarkedCorrect 130 | ] 131 | [ text answer ] 132 | ) 133 | problem.answers 134 | ) 135 | ] 136 | ] 137 | 138 | 139 | evaluate : Problem -> Guess -> Float 140 | evaluate problem guess = 141 | -- a correct guess maps to a higher evaluation 142 | if (guess == problem.correct) then 143 | 100 144 | else 145 | 0 146 | 147 | 148 | problemGenerator : Random.Generator Problem 149 | problemGenerator = 150 | generatorFromList 151 | { question = "🇱🇻 Latvia" 152 | , answers = [ "Tallin", "Riga", "Vilnius", "Moscow" ] 153 | , correct = 1 154 | } 155 | -- a list of problems identical to the record above 156 | problems 157 | 158 | 159 | 160 | -- Ports 161 | 162 | 163 | port outgoing : Encode.Value -> Cmd msg 164 | 165 | 166 | port incoming : (Encode.Value -> msg) -> Sub msg 167 | 168 | 169 | 170 | -- Problem DB 171 | 172 | 173 | problems : List Problem 174 | problems = 175 | [ { question = "🇭🇺 Hungary" 176 | , answers = [ "Budapest", "Pécs", "Mosonmagyaróvár", "Garmisch-Partenkirchen" ] 177 | , correct = 0 178 | } 179 | , { question = "🇧🇳 Brunei" 180 | , answers = [ "Munich", "Copenhagen", "Jakarta", "Bandar Seri Begawan" ] 181 | , correct = 3 182 | } 183 | , { question = "🇸🇮 Slovenia" 184 | , answers = [ "Munich", "Ljubljana", "Jakarta", "Bandar Seri Begawan" ] 185 | , correct = 1 186 | } 187 | , { question = "🇫🇷 France" 188 | , answers = [ "Bordeaux", "Paris", "Paris, NY" ] 189 | , correct = 1 190 | } 191 | , { question = "🇩🇪 Germany" 192 | , answers = [ "Bordeaux", "Berlin, NH", "Berlin", "Stuttgart" ] 193 | , correct = 2 194 | } 195 | , { question = "🇩🇰 Denmark" 196 | , answers = [ "Ansterdam", "Aarhus", "Copenhagen", "Christiania" ] 197 | , correct = 2 198 | } 199 | ] 200 | 201 | 202 | 203 | -- Styles 204 | 205 | 206 | containerStyle : List ( String, String ) 207 | containerStyle = 208 | [ ( "width", "100%" ) 209 | , ( "height", "100%" ) 210 | , ( "display", "flex" ) 211 | , ( "align-items", "center" ) 212 | , ( "justify-content", "center" ) 213 | ] 214 | 215 | 216 | contentStyle : List ( String, String ) 217 | contentStyle = 218 | [ ( "margin", "0" ) 219 | , ( "width", "auto" ) 220 | , ( "max-width", "440px" ) 221 | , ( "text-align", "center" ) 222 | ] 223 | 224 | 225 | listStyle : List ( String, String ) 226 | listStyle = 227 | [ ( "list-style", "none" ) 228 | , ( "padding-left", "0" ) 229 | ] 230 | 231 | 232 | listItemBaseStyle : Bool -> Bool -> List ( String, String ) 233 | listItemBaseStyle isGuessedBySelf isMarkedCorrect = 234 | [ ( "margin", "12px" ) 235 | , ( "display", "inline-block" ) 236 | , ( "border-width", "1px" ) 237 | , ( "border-style", "solid" ) 238 | , ( "cursor", "pointer" ) 239 | , ( "padding", "8px 16px" ) 240 | , ( "border-radius", "6px" ) 241 | ] 242 | ++ [ ( "border-color" 243 | , if isGuessedBySelf then 244 | "#333333" 245 | else 246 | "transparent" 247 | ) 248 | , ( "background-color" 249 | , if isMarkedCorrect then 250 | "#333333" 251 | else 252 | "transparent" 253 | ) 254 | , ( "color" 255 | , if isMarkedCorrect then 256 | "#FFFFFF" 257 | else 258 | "#333333" 259 | ) 260 | ] 261 | -------------------------------------------------------------------------------- /src/Data/Room.elm: -------------------------------------------------------------------------------- 1 | module Data.Room exposing (..) 2 | 3 | import Dict 4 | import Json.Decode as JD 5 | import Json.Encode as JE 6 | import Data.Player as Player 7 | import Data.Round as Round 8 | 9 | 10 | -- Type definitions 11 | 12 | 13 | type alias RoomId = 14 | String 15 | 16 | 17 | type alias Room problem guess = 18 | { id : String 19 | , host : Player.PlayerId 20 | , round : Maybe (Round.Round problem) 21 | , players : Dict.Dict String (Player.Player guess) 22 | } 23 | 24 | 25 | 26 | -- Helpers 27 | 28 | 29 | create : RoomId -> List Player.PlayerId -> Room problem guess 30 | create roomId playerIds = 31 | { id = roomId 32 | , host = playerIds |> List.head |> Maybe.withDefault "" 33 | , round = Nothing 34 | , players = Dict.fromList (List.map (\playerId -> ( playerId, Player.create playerId roomId )) playerIds) 35 | } 36 | 37 | 38 | updatePreservingLocalGuesses : Room problem guess -> Room problem guess -> Room problem guess 39 | updatePreservingLocalGuesses newRoom oldRoom = 40 | -- Since the application state only listens to full room updates, 41 | -- it can occur that one player's guess is nulled by another player's update. 42 | -- This method protects agains that. 43 | let 44 | didRoundChange = 45 | Maybe.map2 46 | (\newRound round -> newRound.no /= round.no) 47 | newRoom.round 48 | oldRoom.round 49 | |> Maybe.withDefault True 50 | 51 | newPlayers = 52 | newRoom.players 53 | |> Dict.toList 54 | |> List.map 55 | (\( playerId, player ) -> 56 | ( playerId 57 | , { player 58 | | guess = 59 | -- If the round didn't change, and the new guess is Nothing 60 | -- keep the old guess, which may be something. 61 | if 62 | (not didRoundChange 63 | && (player.guess == Nothing) 64 | ) 65 | then 66 | oldRoom.players 67 | |> Dict.get playerId 68 | |> Maybe.map .guess 69 | |> Maybe.withDefault player.guess 70 | else 71 | player.guess 72 | } 73 | ) 74 | ) 75 | |> Dict.fromList 76 | in 77 | { newRoom | players = newPlayers } 78 | 79 | 80 | allPlayersReady : Room problem guess -> Bool 81 | allPlayersReady room = 82 | room.players 83 | |> Dict.toList 84 | |> List.map Tuple.second 85 | |> List.map .isReady 86 | |> List.all identity 87 | 88 | 89 | allPlayersGuessed : Room problem guess -> Bool 90 | allPlayersGuessed room = 91 | room.players 92 | |> Dict.toList 93 | |> List.map Tuple.second 94 | |> List.map .guess 95 | |> List.all ((/=) Nothing) 96 | 97 | 98 | bigNumber : Float 99 | bigNumber = 100 | 100000 101 | 102 | 103 | getRoundWinner : (problem -> guess -> Float) -> Maybe Float -> Room problem guess -> Maybe Player.PlayerId 104 | getRoundWinner evaluate clearWinnerEvaluation room = 105 | room.players 106 | |> Dict.toList 107 | |> List.filterMap 108 | (\( playerId, player ) -> 109 | player.guess 110 | |> Maybe.map2 111 | (\round guess -> 112 | ( playerId 113 | , evaluate round.problem guess.value 114 | , guess.madeAt 115 | ) 116 | ) 117 | room.round 118 | ) 119 | |> List.sortWith 120 | (\( playerId1, eval1, madeAt1 ) ( playerId2, eval2, madeAt2 ) -> 121 | if eval1 > eval2 then 122 | LT 123 | else if eval1 < eval2 then 124 | GT 125 | else 126 | (if madeAt1 > madeAt2 then 127 | GT 128 | else 129 | LT 130 | ) 131 | ) 132 | |> List.head 133 | |> Maybe.map 134 | (\( playerId, eval, _ ) -> 135 | case clearWinnerEvaluation of 136 | Just clearWinnerEval -> 137 | if eval == clearWinnerEval then 138 | Just playerId 139 | else 140 | Nothing 141 | 142 | Nothing -> 143 | Just playerId 144 | ) 145 | |> Maybe.withDefault Nothing 146 | 147 | 148 | updatePlayer : 149 | (Player.Player guess -> Player.Player guess) 150 | -> Player.PlayerId 151 | -> Room problem guess 152 | -> Room problem guess 153 | updatePlayer transform playerId room = 154 | case (Dict.get playerId room.players) of 155 | Just player -> 156 | { room | players = Dict.insert playerId (transform player) room.players } 157 | 158 | Nothing -> 159 | room 160 | 161 | 162 | setScores : 163 | Maybe Player.PlayerId 164 | -> Room problem guess 165 | -> Room problem guess 166 | setScores maybeWinnerId room = 167 | { room 168 | | players = 169 | case maybeWinnerId of 170 | Just winnerId -> 171 | room.players 172 | |> Dict.toList 173 | |> List.map 174 | (\( playerId, player ) -> 175 | if playerId == winnerId then 176 | ( playerId 177 | , { player 178 | | score = player.score + 1 179 | } 180 | ) 181 | else 182 | ( playerId 183 | , player 184 | ) 185 | ) 186 | |> Dict.fromList 187 | 188 | Nothing -> 189 | room.players 190 | } 191 | 192 | 193 | 194 | -- Encoders 195 | 196 | 197 | encoder : (problem -> JE.Value) -> (guess -> JE.Value) -> Room problem guess -> JE.Value 198 | encoder problemEncoder guessEncoder room = 199 | JE.object <| 200 | [ ( "id", JE.string room.id ) 201 | , ( "host", JE.string room.host ) 202 | , ( "players", Player.collectionEncoder guessEncoder room.players ) 203 | ] 204 | ++ (room.round 205 | |> Maybe.map 206 | (\round -> 207 | [ ( "round", Round.encoder problemEncoder round ) 208 | ] 209 | ) 210 | |> Maybe.withDefault [] 211 | ) 212 | 213 | 214 | 215 | -- Decoders 216 | 217 | 218 | decoder : JD.Decoder problem -> JD.Decoder guess -> JD.Decoder (Room problem guess) 219 | decoder problemDecoder guessDecoder = 220 | JD.map4 Room 221 | (JD.field "id" JD.string) 222 | (JD.field "host" JD.string) 223 | (JD.maybe (JD.field "round" (Round.decoder problemDecoder))) 224 | (JD.field "players" (JD.dict (Player.decoder guessDecoder))) 225 | -------------------------------------------------------------------------------- /src/js/db/webrtc.js: -------------------------------------------------------------------------------- 1 | // Stores room state, peer clients and connections by room id 2 | ;(function (root, factory) { 3 | if (typeof module === 'object' && module.exports) { 4 | module.exports = factory() 5 | } else { 6 | root.db = factory() 7 | } 8 | }(this, function () { 9 | var peerOptions = { 10 | key: 'lwjd5qra8257b9' 11 | } 12 | 13 | var log = console.log.bind(console) 14 | 15 | function memoize (fn) { 16 | return function () { 17 | var args = Array.prototype.slice.call(arguments) 18 | var hash = args.reduce(function (acc, current) { 19 | return acc + ((current === Object(current)) ? JSON.stringify(current) : current) 20 | }, '') 21 | fn.memo = fn.memo || {} 22 | if (fn.memo[hash]) { 23 | return fn.memo[hash] 24 | } 25 | var returnValue = fn.apply(this, args) 26 | fn.memo[hash] = returnValue 27 | return returnValue 28 | } 29 | } 30 | 31 | var loadPeerJs = memoize(function loadPeerJs () { 32 | var url = 'http://cdn.peerjs.com/0.3/peer.js' 33 | return new Promise(function (resolve, reject) { 34 | var scriptTag = document.createElement('script') 35 | scriptTag.src = url 36 | scriptTag.onload = function () { 37 | resolve(window.Peer) 38 | } 39 | document.body.appendChild(scriptTag) 40 | }) 41 | }) 42 | 43 | var db = function () { 44 | var rooms = {} 45 | 46 | function updateSubscribers (roomId) { 47 | if (!isHost(roomId)) { 48 | return 49 | } 50 | rooms[roomId].subscribers.forEach(function (connection) { 51 | connection.send({ 52 | type: 'room:updated', 53 | payload: rooms[roomId].state 54 | }) 55 | }) 56 | rooms[roomId].ownSubscribers.forEach(function (subscriber) { 57 | subscriber(rooms[roomId].state) 58 | }) 59 | } 60 | 61 | var connectToHost = memoize(function (roomId) { 62 | return loadPeerJs().then(function (Peer) { 63 | return new Promise(function (resolve, reject) { 64 | var room = rooms[roomId] 65 | var peer 66 | if (room && room.peer) { 67 | peer = room.peer 68 | } else { 69 | peer = new Peer('elm-gameroom-' + roomId + '-' + new Date().getTime(), peerOptions) 70 | peer.on('error', log) 71 | } 72 | var connection 73 | if (room && room.connectionToHost) { 74 | connection = room.connectionToHost 75 | } else { 76 | connection = peer.connect('elm-gameroom-' + roomId) 77 | connection.on('error', log) 78 | } 79 | rooms[roomId] = { 80 | peer: peer, 81 | connectionToHost: connection, 82 | isHost: false 83 | } 84 | connection.on('open', function () { 85 | resolve(connection) 86 | }) 87 | }) 88 | }) 89 | }) 90 | 91 | function isHost (roomId) { 92 | return rooms[roomId] && rooms[roomId].isHost 93 | } 94 | 95 | return { 96 | getRoom: function (roomId) { 97 | if (isHost(roomId)) { 98 | return Promise.resolve(rooms[roomId].state) 99 | } else { 100 | return loadPeerJs().then(function (Peer) { 101 | return connectToHost(roomId).then(function (connection) { 102 | return new Promise(function (resolve, reject) { 103 | var onData = function (data) { 104 | resolve(data) 105 | } 106 | connection.on('data', onData) 107 | connection.send({ 108 | type: 'get:room', 109 | payload: { 110 | roomId: roomId 111 | } 112 | }) 113 | }) 114 | }) 115 | .catch(log) 116 | }) 117 | } 118 | }, 119 | 120 | createRoom: function (room) { 121 | return loadPeerJs().then(function (Peer) { 122 | var peer = new Peer('elm-gameroom-' + room.id, peerOptions) 123 | rooms[room.id] = { 124 | peer: peer, 125 | isHost: true, 126 | connectionToHost: null, 127 | state: room, 128 | subscribers: [], 129 | ownSubscribers: [] 130 | } 131 | peer.on('connection', function (connection) { 132 | connection.on('data', function (msg) { 133 | switch (msg.type) { 134 | // Subscribe to room, sending room:updated messages 135 | case 'get:room': 136 | return connection.send(rooms[room.id].state) 137 | case 'subscribeto:room': 138 | connection.send({ 139 | type: 'room:updated', 140 | payload: rooms[room.id].state 141 | }) 142 | rooms[room.id].subscribers.push(connection) 143 | return 144 | case 'unsubscribefrom:room': 145 | // This need not be handled, as closed connections are removed automatically 146 | return 147 | case 'update:room': 148 | rooms[room.id].state = msg.payload 149 | updateSubscribers(room.id) 150 | return 151 | case 'update:player': 152 | rooms[room.id].state.players[msg.payload.id] = msg.payload 153 | updateSubscribers(room.id) 154 | return 155 | } 156 | }) 157 | }) 158 | }) 159 | .catch(log) 160 | }, 161 | 162 | subscribeToRoom: function (roomId, onValue) { 163 | if (isHost(roomId)) { 164 | rooms[roomId].ownSubscribers.push(onValue) 165 | onValue(rooms[roomId].state) 166 | } else { 167 | return connectToHost(roomId).then(function (connection) { 168 | connection.on('data', function (msg) { 169 | if (msg.type === 'room:updated') { 170 | onValue(msg.payload) 171 | } 172 | }) 173 | connection.send({ 174 | type: 'subscribeto:room', 175 | payload: roomId 176 | }) 177 | }) 178 | } 179 | }, 180 | 181 | unsubscribeFromRoom: function (roomId) { 182 | if (isHost(roomId)) { 183 | rooms[roomId].ownSubscribers = [] 184 | } else { 185 | // TODO: handle this case 186 | return 187 | } 188 | }, 189 | 190 | updateRoom: function (room) { 191 | if (isHost(room.id)) { 192 | rooms[room.id].state = room 193 | updateSubscribers(room.id) 194 | return Promise.resolve(room) 195 | } else { 196 | return connectToHost(room.id).then(function (connection) { 197 | connection.send({ 198 | type: 'update:room', 199 | payload: room 200 | }) 201 | return connection 202 | }) 203 | .then(function (connection) { 204 | return room 205 | }) 206 | .catch(log) 207 | } 208 | }, 209 | 210 | updatePlayer: function (player) { 211 | if (isHost(player.roomId)) { 212 | rooms[player.roomId].state.players[player.id] = player 213 | updateSubscribers(player.roomId) 214 | return Promise.resolve(player) 215 | } else { 216 | return connectToHost(player.roomId).then(function (connection) { 217 | connection.send({ 218 | type: 'update:player', 219 | payload: player 220 | }) 221 | return connection 222 | }) 223 | .then(function (connection) { 224 | return player 225 | }) 226 | .catch(log) 227 | } 228 | } 229 | } 230 | } 231 | 232 | return db 233 | })) 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏓 elm-gameroom 2 | 3 | This is a framework for creating multiplayer guessing games by the boatloads, all within the comfort of Elm. Specify only what is unique to each game, write no logic on the back-end, and have it all wired up and ready to play. 4 | 5 | `elm-gameroom` takes care of calling game rounds, generating problems and reconciling scores, all the while talking to either a generic real-time database such as Firebase (adapter provided), or have clients sort things out amongst themselves via WebRTC (JavaScript glue code provided). 6 | 7 | For some context, see [this talk](https://www.youtube.com/watch?v=sBCz6atTRZk). 8 | 9 | ## Running the examples 10 | 11 | You can try some games made with the framework by playing in two tabs of the same browser - and this will be tremendously useful as you write your own games later on. 12 | 13 | To do that, install elm-live: `npm i -g elm-live`. Then run the following command: `bin/example.sh $NAME`, where `$NAME` is a folder name within `./examples`, either `counterclockwooze`, `lettero`, `spacecraterball` or `the-capitalist`. 14 | 15 | ## Making your own game 16 | 17 | To create a simple trivia game, all you need is yay'much (head to the [The Capitalist](/examples/the-capitalist/Main.elm) for a version that is a bit longer and broken down): 18 | 19 | ```elm 20 | module Main exposing (..) 21 | 22 | import Html exposing (..) 23 | import Html.Events exposing (onClick) 24 | import Json.Encode as Encode 25 | import Json.Decode as Decode 26 | import Gameroom exposing (..) 27 | 28 | -- The problem is record containing 29 | -- a question, possible answers 30 | -- and the index of the correct answer. 31 | type alias Problem = 32 | { question : String 33 | , answer : List String 34 | , correct : Int 35 | } 36 | 37 | -- The guess is an integer holding the index of the correct answer 38 | type alias Guess = Int 39 | 40 | -- The main program uses the `game` method, which takes a spec object. 41 | main = 42 | game 43 | { view = 44 | -- Takes a view context and a problem 45 | -- returns Html that emits raw guesses. 46 | (\context problem -> 47 | div 48 | [] 49 | [ h1 [] [ text problem.question ] 50 | , ul [] 51 | <| List.indexedMap 52 | (\index answer -> 53 | li 54 | --Cclicks on an answer 55 | -- emit raw guesses. 56 | [ onClick index 57 | ] 58 | [ text answer 59 | ] 60 | ) 61 | problem.answers 62 | ] 63 | ) 64 | , evaluate = 65 | -- Correct guess evaluates to 100, an incorrect one to 0. 66 | (\problem guess -> 67 | if guess == problem.correct then 100 else 0 68 | ) 69 | , problemGenerator = 70 | generatorFromList 71 | -- Default problem 72 | { question = "Which one is not a taste?" 73 | , answers = [ "salty", "sour", "notebook" ], 74 | , correct = 2 75 | } 76 | [ -- A list of additional problems. 77 | -- `generatorFromList` will generate randomly 78 | -- from this list in each round. 79 | ] 80 | -- Some encoders and decoders, 81 | -- required to transfer data between machines 82 | -- in multiplayer mode. 83 | , guessEncoder = Encode.string 84 | , guessDecoder = Decode.string 85 | , problemEncoder = 86 | (\pb -> 87 | Encode.object 88 | [ ( "question", Encode.string pb.question ) 89 | , ( "answers", 90 | List.map Encode.string pb.answers 91 | |> Encode.list 92 | ) 93 | , ( "correct", Encode.int pb.correct ) 94 | ] 95 | ) 96 | , problemDecoder = 97 | Decode.map3 Problem 98 | (Decode.field "question" Decode.string) 99 | (Decode.field "answers" (Decode.list Decode.string)) 100 | (Decode.field "correct" Decode.int) 101 | } 102 | ``` 103 | 104 | And there you have it - the barebones of your game are defined. There are a couple of steps to take until this becomes playable in multiplayer, but this already renders the tutorial section, so you can get a feel for how your game would play. 105 | 106 | ### Multiplayer functionality 107 | 108 | In order to set up communication between machines, the data needs to go from the Elm app to the outside world. In order to keep the back-end generic, this is done through ports. Compared to the example above, the following modifications are needed: 109 | 110 | ```elm 111 | port outgoing : Encode.Value -> Cmd msg 112 | 113 | port incoming : (Encode.Value -> msg) -> Sub msg 114 | 115 | main = 116 | gameWith 117 | [ responsiblePorts { incoming = incoming, outgoing = outgoing } ] 118 | { -- spec object from before 119 | } 120 | ``` 121 | 122 | Instead of `game`, we now use `gameWith`, which allows a list of settings to be passed to the game program constructor. The first and most important such setting is `responsiblePorts`, which expects a record of incoming and outgoing ports. 123 | 124 | We still need to wire up these ports (described right below), but once we do, the game is deployable and playable with any number of players. And it's just boilerplate, I promise :). 125 | 126 | ### The JavaScript 127 | 128 | Under `./src/js` in this repo, you'll find two types of JavaScript files: `db/*.js` and `talk-to-ports.js`. The `db` files are different implementations of the same promise-based API that the client can use to subscribe to a room, send updates to both players and the whole game room, etc. This you can implement yourself for your choice of datastore, or you can use the implementations we provide for `Firebase`, `WebRTC` (Android phones in luck) and `localStorage` (this one assumes that you're in the same browser, so you can test the games you're making in multiplayer). `talk-to-ports.js` wires things up between this database API and the ports we just defined, like so: 129 | 130 | ```html 131 | 132 | 137 | 138 | 139 | 143 | ``` 144 | 145 | And there you have it, the game is fully functional in multiplayer! 146 | 147 | ### Customization 148 | 149 | Lots more customization options are available: 150 | 151 | ```elm 152 | main = 153 | gameWith 154 | [ name "MyCoolGame" 155 | , roundDuration (10 * Time.second) 156 | , cooldownDuration (4 * Time.second) 157 | , clearWinner 100 158 | , noPeripheralUi 159 | ] 160 | { -- spec object from before 161 | } 162 | ``` 163 | 164 | This produces a game with a custom name, custom round duration, custom cooldown duration between rounds, a clear winner at evaluation 100 (meaning no player can win unless their guess evaluates to exactly 100), and disable the peripheral ui - the score board, timer and winner notifications - so you can build those yourself in whichever design you prefer. 165 | 166 | ## But how does it work? 167 | 168 | `elm-gameroom` aims to keep as much logic and responsibility on the client, so that new games can be set up easily. This involves a fair bit of reconciliation, juggling around race conditions etc., but I'll just start with the gist: 169 | 170 | When a client creates a game room, it becomes the room's host, meaning that it will run decisive game logic such as updating scores and generating game problems in its browser. It then pushes updates to wherever the game state lives, whether it's a Firebase datastore or its very own memory. The other clients subscribe to this datastore, and only send their individual guesses to it. 171 | 172 | Working this way allows most of the logic to live in Elm, and keep outside code as thin as possible. 173 | 174 | ## Long story short 175 | 176 | `elm-gameroom` aims to make it easy for you to make games, so you can make loads of them and focus on what's interesting. Please let me know what you make, and what I can do to make the process nicer for you :). 177 | -------------------------------------------------------------------------------- /src/Page/Game/Update.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Update exposing (..) 2 | 3 | import Dict 4 | import Json.Encode as JE 5 | import Data.OutgoingMessage as OutgoingMessage 6 | import Page.Game.Messages exposing (Msg(..)) 7 | import Page.Game.Models exposing (Model, setOwnGuess, getOwnPlayer) 8 | import Data.Room as Room 9 | import Data.Player as Player 10 | import Data.RoundTime as RoundTime 11 | import Data.Spec as Spec 12 | 13 | 14 | updateRoomCmd : 15 | Spec.DetailedSpec problem guess 16 | -> Model problem guess 17 | -> Maybe JE.Value 18 | updateRoomCmd spec model = 19 | model.room 20 | |> Maybe.map (OutgoingMessage.UpdateRoom >> (OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder)) 21 | 22 | 23 | updatePlayerCmd : 24 | Spec.DetailedSpec problem guess 25 | -> Maybe (Player.Player guess) 26 | -> Maybe JE.Value 27 | updatePlayerCmd spec player = 28 | player 29 | |> Maybe.map (OutgoingMessage.UpdatePlayer >> (OutgoingMessage.encoder spec.problemEncoder spec.guessEncoder)) 30 | 31 | 32 | update : 33 | Spec.DetailedSpec problem guess 34 | -> Msg problem guess 35 | -> Model problem guess 36 | -> ( Model problem guess, List JE.Value, Bool ) 37 | update spec msg model = 38 | case ( msg, model.room ) of 39 | ( ReceiveUpdate room, Just prevRoom ) -> 40 | let 41 | isHost = 42 | room.host == model.playerId 43 | 44 | isNewRound = 45 | Maybe.map2 (\newRound oldRound -> newRound.no /= oldRound.no) room.round prevRoom.round |> Maybe.withDefault True 46 | 47 | allPlayersReady = 48 | Room.allPlayersReady room 49 | 50 | prevAllPlayersReady = 51 | Room.allPlayersReady prevRoom 52 | 53 | resetTime = 54 | isNewRound || (allPlayersReady && (not prevAllPlayersReady)) 55 | 56 | initiateNewRound = 57 | isHost && allPlayersReady && (room.round == Nothing) 58 | in 59 | ( { model 60 | | room = Just room 61 | , time = 62 | if resetTime then 63 | RoundTime.init 64 | else 65 | model.time 66 | } 67 | , [] 68 | , initiateNewRound 69 | ) 70 | 71 | ( ReceiveUpdate room, Nothing ) -> 72 | ( { model | room = Just room }, [], False ) 73 | 74 | ( ReceiveNewProblem problem, Just room ) -> 75 | let 76 | newRound = 77 | room.round 78 | |> Maybe.map 79 | (\round -> 80 | { no = round.no + 1 81 | , problem = problem 82 | } 83 | ) 84 | |> Maybe.withDefault 85 | { no = 0 86 | , problem = problem 87 | } 88 | |> Just 89 | 90 | newRoom = 91 | { room 92 | | round = newRound 93 | , players = Dict.map (\playerId player -> { player | guess = Nothing }) room.players 94 | } 95 | 96 | newModel = 97 | { model 98 | | room = Just newRoom 99 | , time = RoundTime.init 100 | } 101 | in 102 | ( newModel 103 | , [ updateRoomCmd spec newModel ] |> List.filterMap identity 104 | , False 105 | ) 106 | 107 | ( ReceiveNewProblem problem, Nothing ) -> 108 | -- Impossible state 109 | ( model, [], False ) 110 | 111 | ( Guess guess, Just room ) -> 112 | let 113 | isRoundOver = 114 | RoundTime.timeSinceNewRound model.time > spec.roundDuration 115 | 116 | newModel = 117 | setOwnGuess guess model 118 | 119 | newPlayer = 120 | getOwnPlayer newModel 121 | in 122 | if isRoundOver then 123 | ( model, [], False ) 124 | else 125 | ( newModel, [ updatePlayerCmd spec newPlayer ] |> List.filterMap identity, False ) 126 | 127 | ( Guess guess, Nothing ) -> 128 | -- Impossible state 129 | ( model, [], False ) 130 | 131 | ( MarkReady, Just room ) -> 132 | let 133 | newRoom = 134 | room 135 | |> (Room.updatePlayer 136 | (\pl -> { pl | isReady = not pl.isReady }) 137 | model.playerId 138 | ) 139 | 140 | newModel = 141 | { model 142 | | room = Just newRoom 143 | , time = RoundTime.init 144 | } 145 | in 146 | ( newModel 147 | , [ updateRoomCmd spec newModel ] |> List.filterMap identity 148 | , False 149 | ) 150 | 151 | ( MarkReady, Nothing ) -> 152 | -- Impossible state 153 | ( model, [], False ) 154 | 155 | ( Tick time, Just room ) -> 156 | let 157 | potentialRoundWinner = 158 | Room.getRoundWinner spec.evaluate spec.clearWinnerEvaluation room 159 | 160 | allPlayersReady = 161 | Room.allPlayersReady room 162 | 163 | isHost = 164 | room.host == model.playerId 165 | 166 | newTime = 167 | RoundTime.update time model.time 168 | 169 | isRoundJustOver = 170 | RoundTime.justPassed 171 | spec.roundDuration 172 | model.time 173 | newTime 174 | 175 | isCooldownJustOver = 176 | RoundTime.justPassed 177 | (spec.roundDuration + spec.cooldownDuration) 178 | model.time 179 | newTime 180 | 181 | initiateNewRound = 182 | isHost 183 | && ((room.round == Nothing) || isCooldownJustOver) 184 | 185 | ( newRoom, isScoreSet ) = 186 | if (isHost && isRoundJustOver) then 187 | (case potentialRoundWinner of 188 | Just winnerId -> 189 | ( if room.host == model.playerId then 190 | Room.setScores (Just winnerId) room 191 | else 192 | room 193 | , True 194 | ) 195 | 196 | Nothing -> 197 | ( if room.host == model.playerId then 198 | Room.setScores Nothing room 199 | else 200 | room 201 | , True 202 | ) 203 | ) 204 | else 205 | ( room, False ) 206 | 207 | newModel = 208 | { model 209 | | room = 210 | Just newRoom 211 | , time = 212 | if allPlayersReady then 213 | RoundTime.update time model.time 214 | else 215 | model.time 216 | } 217 | in 218 | ( newModel 219 | , [ if isScoreSet then 220 | updateRoomCmd spec newModel 221 | else 222 | Nothing 223 | ] 224 | |> List.filterMap identity 225 | , initiateNewRound 226 | ) 227 | 228 | ( Tick time, Nothing ) -> 229 | ( model 230 | , [] 231 | , False 232 | ) 233 | -------------------------------------------------------------------------------- /src/Page/Game/Views.elm: -------------------------------------------------------------------------------- 1 | module Page.Game.Views exposing (view) 2 | 3 | import Dict 4 | import Window 5 | import Html exposing (Html, div, text, p, h2, ul, li, span, a) 6 | import Html.Attributes exposing (class, style, href) 7 | import Html.Events exposing (onClick) 8 | import Data.Spec as Spec 9 | import Data.Room as Room 10 | import Data.RoundTime as RoundTime 11 | import Page.Game.Messages exposing (Msg(..)) 12 | import Page.Game.Models exposing (Model, isHost) 13 | import Page.Game.Helpers exposing (getNotificationContent) 14 | import Views.Footer as Footer 15 | import Views.Scoreboard as Scoreboard 16 | import Views.Timer as Timer 17 | import Views.Notification as Notification 18 | import Views.Loader as Loader 19 | import Views.NoMultiplayer as NoMultiplayer 20 | import Page.Game.Views.Styles exposing (CssClasses(..), localClass, localClassList) 21 | 22 | 23 | viewReadyPrompt : 24 | Spec.DetailedSpec problem guess 25 | -> Model problem guess 26 | -> Room.Room problem guess 27 | -> Html (Msg problem guess) 28 | viewReadyPrompt spec model room = 29 | div [ localClass [ ReadyPrompt ] ] <| 30 | [ h2 [] [ text ("Hello, gamer " ++ model.playerId ++ "!") ] 31 | , p [] [ text ("And welcome to room " ++ model.roomId ++ ".") ] 32 | ] 33 | ++ (if isHost model then 34 | [ p [] [ text "You are a host for this room. Here are the links to invite them to the game:" ] 35 | , ul [] 36 | (model.room 37 | |> Maybe.map .players 38 | |> Maybe.map Dict.toList 39 | |> Maybe.map 40 | (List.filter (\( playerId, _ ) -> model.playerId /= playerId) 41 | >> List.map 42 | ((\( playerId, player ) -> 43 | a 44 | [ localClassList [ ( Link, True ), ( DisabledLink, playerId == model.playerId ) ] 45 | , href 46 | (spec.basePath 47 | ++ (if spec.basePath == "/" then 48 | "rooms/" 49 | else 50 | "/rooms/" 51 | ) 52 | ++ model.roomId 53 | ++ "/" 54 | ++ playerId 55 | ) 56 | ] 57 | [ text (playerId ++ "'s game link") ] 58 | ) 59 | ) 60 | ) 61 | |> Maybe.withDefault [] 62 | ) 63 | ] 64 | else 65 | [] 66 | ) 67 | ++ [ p [] [ text "Do let us know when you're ready - the game starts immediately once all players marked themselves as such." ] 68 | , ul 69 | [] 70 | (room.players 71 | |> Dict.toList 72 | |> List.map Tuple.second 73 | |> List.map 74 | (\pl -> 75 | li [] 76 | [ span 77 | ([ localClassList 78 | [ ( Link, True ) 79 | , ( DisabledLink, model.playerId /= pl.id ) 80 | ] 81 | ] 82 | ++ (if model.playerId == pl.id then 83 | [ onClick MarkReady ] 84 | else 85 | [] 86 | ) 87 | ) 88 | [ text 89 | (if pl.id == model.playerId then 90 | (if pl.isReady then 91 | "Mark me non-ready" 92 | else 93 | "I feel ready" 94 | ) 95 | else 96 | (pl.id 97 | ++ (if pl.isReady then 98 | " is ready" 99 | else 100 | " is prepping" 101 | ) 102 | ) 103 | ) 104 | ] 105 | ] 106 | ) 107 | ) 108 | ] 109 | 110 | 111 | viewRoom : 112 | Spec.DetailedSpec problem guess 113 | -> Window.Size 114 | -> Model problem guess 115 | -> Room.Room problem guess 116 | -> List (Html (Msg problem guess)) 117 | viewRoom spec windowSize model room = 118 | [ if Room.allPlayersReady room then 119 | (case room.round of 120 | Just round -> 121 | div 122 | [ localClass [ GamePlay ] ] 123 | [ Html.map Guess 124 | (spec.view 125 | { windowSize = windowSize 126 | , roundTime = RoundTime.timeSinceNewRound model.time 127 | , ownGuess = room.players |> Dict.get model.playerId |> Maybe.andThen .guess |> Maybe.map .value 128 | , opponentGuesses = 129 | room.players 130 | |> Dict.toList 131 | |> List.filterMap 132 | (\( playerId, player ) -> 133 | if playerId /= model.playerId then 134 | player.guess 135 | |> Maybe.map .value 136 | |> Maybe.map (\val -> ( playerId, val )) 137 | else 138 | Nothing 139 | ) 140 | , isRoundOver = RoundTime.timeSinceNewRound model.time > spec.roundDuration 141 | , scores = 142 | room.players 143 | |> Dict.toList 144 | |> List.map 145 | (\( id, pl ) -> 146 | ( id, pl.score ) 147 | ) 148 | } 149 | round.problem 150 | ) 151 | ] 152 | 153 | Nothing -> 154 | div [] [ text "Awaiting game" ] 155 | ) 156 | else 157 | viewReadyPrompt spec model room 158 | ] 159 | ++ (if spec.peripheralUi then 160 | [ Notification.view (getNotificationContent spec model) Nothing ] 161 | else 162 | [] 163 | ) 164 | ++ (if (Room.allPlayersReady room) && spec.peripheralUi then 165 | [ Timer.view ((RoundTime.timeSinceNewRound model.time) / spec.roundDuration) ] 166 | else 167 | [] 168 | ) 169 | ++ (if spec.peripheralUi then 170 | [ Footer.view 171 | [ room.players 172 | |> Dict.toList 173 | |> List.map 174 | (\( playerId, player ) -> 175 | ( player.id, player.score ) 176 | ) 177 | |> Scoreboard.view 178 | ] 179 | ] 180 | else 181 | [] 182 | ) 183 | 184 | 185 | view : 186 | Spec.DetailedSpec problem guess 187 | -> Window.Size 188 | -> Model problem guess 189 | -> Html (Msg problem guess) 190 | view spec windowSize model = 191 | div [ localClass [ Root ] ] 192 | (case model.room of 193 | Just room -> 194 | viewRoom spec windowSize model room 195 | 196 | Nothing -> 197 | [ div [ localClass [ LoaderContainer ] ] [ Loader.view ] ] 198 | ) 199 | -------------------------------------------------------------------------------- /src/Gameroom.elm: -------------------------------------------------------------------------------- 1 | module Gameroom 2 | exposing 3 | ( Spec 4 | , Ports 5 | , Msg 6 | , Model 7 | , basePath 8 | , name 9 | , subheading 10 | , instructions 11 | , roundDuration 12 | , cooldownDuration 13 | , clearWinner 14 | , responsiblePorts 15 | , generatorFromList 16 | , noInlineStyle 17 | , noPeripheralUi 18 | , css 19 | , unicodeIcon 20 | , game 21 | , gameWith 22 | ) 23 | 24 | {-| This is a framework for creating [multiplayer Guessing games by the boatloads](https://www.youtube.com/watch?v=sBCz6atTRZk), all within the comfort of Elm. Specify only what is unique to a game, write no logic on the back-end, and have it all wired up and ready to play. 25 | 26 | `elm-gameroom` takes care of calling game rounds, generating problems and reconciling scores, as well as talking to either a generic real-time database such as Firebase (JS adapter provided), with have clients sort things out amongst themselves via WebRTC (JavaScript glue code provided). 27 | 28 | # The main game program 29 | @docs game, gameWith 30 | 31 | # Game spec 32 | @docs Spec 33 | 34 | Use these `Msg` and `Model` types to annotate your program when using the [game](/Gameroom#game) or [gameWith](/Gameroom#gameWith) methods. 35 | @docs Model, Msg 36 | 37 | # Ports 38 | @docs Ports 39 | 40 | # Settings 41 | @docs basePath, name, subheading, instructions, unicodeIcon, clearWinner, roundDuration, cooldownDuration, noInlineStyle, noPeripheralUi, responsiblePorts 42 | 43 | # Miscellaneous 44 | @docs css 45 | 46 | # Utils 47 | @docs generatorFromList 48 | -} 49 | 50 | import Window 51 | import Task 52 | import Time 53 | import Navigation 54 | import Random 55 | import Css 56 | import Models 57 | import Data.Ports as Ports 58 | import Subscriptions exposing (subscriptions) 59 | import Messages 60 | import Update exposing (update, cmdOnRouteChange) 61 | import Router 62 | import Data.Route as Route 63 | import Data.Ports as Ports 64 | import Data.Spec as Spec exposing (Setting(..), buildDetailedSpec) 65 | import Views exposing (view) 66 | import Views.Layout 67 | 68 | 69 | {-| Define the unique bits and pieces to your game, all generalized over a type variable representing a `problem`, and one representing a `guess`. It's going to look a little heavy, but it'll make sense very quickly, I promise. Here it goes: 70 | 71 | type alias Spec problem guess = 72 | { view : Context guess -> problem -> Html guess 73 | , evaluate : problem -> guess -> Float 74 | , problemGenerator : Random.Generator problem 75 | , problemEncoder : problem -> Encode.Value 76 | , problemDecoder : Decode.Decoder problem 77 | , guessEncoder : guess -> Encode.Value 78 | , guessDecoder : Decode.Decoder guess 79 | } 80 | 81 | * view: The core of the user interface corresponding to the current game round, excluding all navigation, notifications and the score boards. Emits guesses. The first argument is a view context containing peripheral information such as window size, round time, already recorded guesses etc., and it's [documented on its own](/Gameroom-Context). The second, main argument is the current game problem. 82 | * evaluate: given a problem and a guess, returns a numerical evaluation of the guess. The player with the highest evaluation wins a given round. Note that this is affected by the [clearWinner](/Gameroom#clearWinner) setting, which specifies that only by attaining a certain highest evaluation can a player win. 83 | * problemGenerator: a random generator churning out new problems. If your problems are a simple list, there is a [convenient helper](/Gameroom#generatorFromList). 84 | -} 85 | type alias Spec problem guess = 86 | Spec.Spec problem guess 87 | 88 | 89 | {-| Msg type alias for the game program. 90 | -} 91 | type alias Msg problem guess = 92 | Messages.Msg problem guess 93 | 94 | 95 | {-| Model type alias for the game program. 96 | -} 97 | type alias Model problem guess = 98 | Models.Model problem guess 99 | 100 | 101 | {-| The Ports record contains incoming and outgoing ports necessary for a guessing game, like so: 102 | 103 | port outgoing : Json.Encode.Value -> Cmd msg 104 | 105 | port incoming : (Json.Encode.Value -> msg) -> Sub msg 106 | 107 | ports = { incoming = incoming, outgoing = outgoing } 108 | -} 109 | type alias Ports msg = 110 | Ports.Ports msg 111 | 112 | 113 | {-| If your game doesn't start at the root route, you need to tell the package so the routing is done correctly, e.g. `basePath "/game1"`. This is useful if you want to host multiple games on the same domain, and have them share data stores. This is how the demo site is set up :). 114 | 115 | You can omit the leading slash or have an extra trailing slash. However, base paths with inner slashes such as `/games/game1` are currently not supported. 116 | -} 117 | basePath : String -> Setting problem guess 118 | basePath url = 119 | BasePath url 120 | 121 | 122 | {-| Set the duration of the game round (how long players have to make their guesses). 123 | -} 124 | roundDuration : Time.Time -> Setting problem guess 125 | roundDuration duration = 126 | RoundDuration duration 127 | 128 | 129 | {-| Set the duration of the cooldown phase after a game round is over. 130 | -} 131 | cooldownDuration : Time.Time -> Setting problem guess 132 | cooldownDuration duration = 133 | CooldownDuration duration 134 | 135 | 136 | {-| The name of your game, e.g. `name "YouWillSurelyLose"`. 137 | -} 138 | name : String -> Setting problem guess 139 | name name_ = 140 | Name name_ 141 | 142 | 143 | {-| A subheading to go under the name on the home page. 144 | -} 145 | subheading : String -> Setting problem guess 146 | subheading subheading_ = 147 | Subheading subheading_ 148 | 149 | 150 | {-| Instructions displayed in the tutorial section. 151 | -} 152 | instructions : String -> Setting problem guess 153 | instructions instructions_ = 154 | Instructions instructions_ 155 | 156 | 157 | {-| A unicode icon for your game. 158 | -} 159 | unicodeIcon : String -> Setting problem guess 160 | unicodeIcon icon_ = 161 | UnicodeIcon icon_ 162 | 163 | 164 | {-| In the most general case, players compete in getting as close as possible to a given goal. However, sometimes you might want to simplify the game and designate winners only if they attained a specific evaluation value specified by `Spec.evaluate`. 165 | 166 | If you use the clearWinner setting, make sure `evaluate` does not depend on the timestamp. 167 | -} 168 | clearWinner : Float -> Setting problem guess 169 | clearWinner maxEvaluation = 170 | ClearWinner maxEvaluation 171 | 172 | 173 | {-| By default, the game interface renders an inline